f99010fae1
Desktop Artifacts / Desktop Build (Linux) (push) Waiting to run
Desktop Artifacts / Desktop Build (Windows) (push) Waiting to run
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Waiting to run
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
503 lines
15 KiB
Go
503 lines
15 KiB
Go
package server_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"go.kenn.io/agentsview/internal/activity"
|
|
"go.kenn.io/agentsview/internal/db"
|
|
"go.kenn.io/agentsview/internal/export"
|
|
"go.kenn.io/agentsview/internal/pricing"
|
|
)
|
|
|
|
// activityDate is a fixed past calendar day so the activity report is
|
|
// deterministic and complete (non-partial) regardless of wall clock.
|
|
const activityDate = "2025-06-02"
|
|
|
|
// seedActivityReportFixture seeds two sessions that overlap in wall-clock
|
|
// on activityDate so peak concurrency is 2. Each session gets distinct,
|
|
// increasing per-message timestamps inside the day (under the 300s gap
|
|
// cap) so the aggregator produces active intervals; the default
|
|
// seedMessages timestamp would yield zero activity.
|
|
func seedActivityReportFixture(t *testing.T, te *testEnv) {
|
|
t.Helper()
|
|
|
|
type entry struct {
|
|
id, project, agent, started, ended string
|
|
msgTimes []string
|
|
}
|
|
entries := []entry{
|
|
{
|
|
id: "d1", project: "alpha", agent: "claude",
|
|
started: activityDate + "T10:00:00Z",
|
|
ended: activityDate + "T10:08:00Z",
|
|
msgTimes: []string{
|
|
activityDate + "T10:00:00Z",
|
|
activityDate + "T10:02:00Z",
|
|
activityDate + "T10:05:00Z",
|
|
activityDate + "T10:07:00Z",
|
|
},
|
|
},
|
|
{
|
|
id: "d2", project: "beta", agent: "codex",
|
|
started: activityDate + "T10:01:00Z",
|
|
ended: activityDate + "T10:09:00Z",
|
|
msgTimes: []string{
|
|
activityDate + "T10:01:00Z",
|
|
activityDate + "T10:03:00Z",
|
|
activityDate + "T10:06:00Z",
|
|
activityDate + "T10:08:00Z",
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, e := range entries {
|
|
started, ended := e.started, e.ended
|
|
te.seedSession(t, e.id, e.project, len(e.msgTimes),
|
|
func(s *db.Session) {
|
|
s.Agent = e.agent
|
|
s.StartedAt = &started
|
|
s.EndedAt = &ended
|
|
},
|
|
)
|
|
times := e.msgTimes
|
|
te.seedMessages(t, e.id, len(times),
|
|
func(i int, m *db.Message) {
|
|
m.Timestamp = times[i]
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestActivityReportEndpoint_Presets(t *testing.T) {
|
|
te := setup(t)
|
|
seedActivityReportFixture(t, te)
|
|
|
|
tests := []struct {
|
|
name string
|
|
params map[string]string
|
|
check func(t *testing.T, resp activity.Report)
|
|
}{
|
|
{
|
|
name: "day",
|
|
params: map[string]string{
|
|
"preset": "day", "date": activityDate, "timezone": "UTC",
|
|
},
|
|
check: func(t *testing.T, resp activity.Report) {
|
|
assert.Equal(t, 2, resp.Peak.Agents)
|
|
assert.Equal(t, 2, resp.Totals.Sessions)
|
|
assert.Equal(t, "minute", resp.BucketUnit)
|
|
assert.False(t, resp.Partial)
|
|
},
|
|
},
|
|
{
|
|
name: "week",
|
|
params: map[string]string{
|
|
"preset": "week", "date": activityDate, "timezone": "UTC",
|
|
},
|
|
check: func(t *testing.T, resp activity.Report) {
|
|
assert.Equal(t, "hour", resp.BucketUnit, "a 7-day week auto-buckets hourly")
|
|
assert.Equal(t, 168, resp.BucketCount)
|
|
},
|
|
},
|
|
{
|
|
name: "month",
|
|
params: map[string]string{
|
|
"preset": "month", "date": activityDate, "timezone": "UTC",
|
|
},
|
|
check: func(t *testing.T, resp activity.Report) {
|
|
assert.Equal(t, "day", resp.BucketUnit, "a 30-day month auto-buckets daily")
|
|
},
|
|
},
|
|
{
|
|
name: "custom",
|
|
params: map[string]string{
|
|
"preset": "custom",
|
|
"from": activityDate + "T00:00:00Z",
|
|
"to": activityDate + "T23:59:59Z",
|
|
"timezone": "UTC",
|
|
},
|
|
check: func(t *testing.T, resp activity.Report) {
|
|
assert.Equal(t, 2, resp.Totals.Sessions)
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
w := te.get(t, buildPathURL("/api/v1/activity/report", tc.params))
|
|
assertStatus(t, w, http.StatusOK)
|
|
tc.check(t, decode[activity.Report](t, w))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestActivityReportJSONIncludesExportMetadata(t *testing.T) {
|
|
te := setup(t)
|
|
seedActivityReportMetadataFixture(t, te)
|
|
|
|
w := te.get(t, buildPathURL("/api/v1/activity/report", map[string]string{
|
|
"preset": "day", "date": activityDate, "timezone": "UTC",
|
|
}))
|
|
assertStatus(t, w, http.StatusOK)
|
|
|
|
resp := decode[activity.Report](t, w)
|
|
assert.Equal(t, export.ActivityReportSchemaVersion,
|
|
resp.SchemaVersion)
|
|
require.NotNil(t, resp.Pricing)
|
|
require.Contains(t, resp.Pricing.Models, "gpt-5.1")
|
|
fallbackModel := activityReportFallbackModel(t)
|
|
require.Contains(t, resp.Pricing.Models, fallbackModel)
|
|
assert.Equal(t, export.CostSourceReported,
|
|
resp.Pricing.Models["gpt-5.1"].CostSource)
|
|
assert.Equal(t, export.CostSourceComputed,
|
|
resp.Pricing.Models[fallbackModel].CostSource)
|
|
assert.True(t, resp.Pricing.Fallback.Used)
|
|
assert.Contains(t, resp.Pricing.Fallback.Models, fallbackModel)
|
|
require.Len(t, resp.Projects, 1)
|
|
for key, project := range resp.Projects {
|
|
assert.NotContains(t, key, "shared-project")
|
|
assert.Equal(t, "shared-project", project.DisplayLabel)
|
|
assert.Equal(t, export.ProjectResolutionUnknown, project.Resolution)
|
|
}
|
|
assert.Equal(t, 2, resp.Totals.Sessions)
|
|
assert.Equal(t, 150, resp.Totals.OutputTokens)
|
|
assert.NotEmpty(t, resp.Buckets)
|
|
assert.Len(t, resp.BySession, 2)
|
|
}
|
|
|
|
func seedActivityReportMetadataFixture(t *testing.T, te *testEnv) {
|
|
t.Helper()
|
|
fallbackModel := activityReportFallbackModel(t)
|
|
seedFallbackModelPricing(t, te.db, fallbackModel)
|
|
started := activityDate + "T10:00:00Z"
|
|
ended := activityDate + "T10:05:00Z"
|
|
for _, id := range []string{"reported-cost", "fallback-cost"} {
|
|
sessionID := "activity-meta-" + id
|
|
te.seedSession(t, sessionID, "shared-project", 2, func(s *db.Session) {
|
|
s.Agent = "codex"
|
|
s.StartedAt = &started
|
|
s.EndedAt = &ended
|
|
s.UserMessageCount = 1
|
|
})
|
|
}
|
|
te.seedMessages(t, "activity-meta-reported-cost", 2,
|
|
func(i int, m *db.Message) {
|
|
m.Timestamp = []string{started, activityDate + "T10:02:00Z"}[i]
|
|
if i == 1 {
|
|
m.Role = "assistant"
|
|
m.Model = "gpt-5.1"
|
|
}
|
|
})
|
|
te.seedMessages(t, "activity-meta-fallback-cost", 2,
|
|
func(i int, m *db.Message) {
|
|
m.Timestamp = []string{started, ended}[i]
|
|
if i == 1 {
|
|
m.Role = "assistant"
|
|
m.Model = fallbackModel
|
|
m.TokenUsage = json.RawMessage(
|
|
`{"input_tokens":200,"output_tokens":100}`)
|
|
}
|
|
})
|
|
cost := 0.25
|
|
ordinal := 1
|
|
require.NoError(t, te.db.ReplaceSessionUsageEvents(
|
|
"activity-meta-reported-cost", []db.UsageEvent{{
|
|
SessionID: "activity-meta-reported-cost", MessageOrdinal: &ordinal,
|
|
Source: "session", Model: "gpt-5.1", InputTokens: 100,
|
|
OutputTokens: 50, CostUSD: &cost,
|
|
OccurredAt: activityDate + "T10:02:00Z",
|
|
DedupKey: "activity-meta-reported-cost:event",
|
|
}},
|
|
))
|
|
}
|
|
|
|
func activityReportFallbackModel(t *testing.T) string {
|
|
t.Helper()
|
|
for _, p := range pricing.FallbackPricing() {
|
|
if p.OutputPerMTok > 0 {
|
|
return p.ModelPattern
|
|
}
|
|
}
|
|
require.FailNow(t, "no fallback model with non-zero output pricing")
|
|
return ""
|
|
}
|
|
|
|
func seedFallbackModelPricing(t *testing.T, database *db.DB, model string) {
|
|
t.Helper()
|
|
for _, p := range pricing.FallbackPricing() {
|
|
if p.ModelPattern != model {
|
|
continue
|
|
}
|
|
require.NoError(t, database.UpsertModelPricing([]db.ModelPricing{{
|
|
ModelPattern: p.ModelPattern,
|
|
InputPerMTok: p.InputPerMTok,
|
|
OutputPerMTok: p.OutputPerMTok,
|
|
CacheCreationPerMTok: p.CacheCreationPerMTok,
|
|
CacheReadPerMTok: p.CacheReadPerMTok,
|
|
}}))
|
|
return
|
|
}
|
|
require.FailNow(t, "fallback model not found", model)
|
|
}
|
|
|
|
// TestActivityReportEndpoint_IncludesOneShotAndAutomated locks in the
|
|
// activity endpoint's hardest binding constraint: unlike the sibling
|
|
// analytics endpoints, it sets ExcludeOneShot=false and
|
|
// ExcludeAutomated=false (see huma_routes_activity.go), so one-shot
|
|
// (user_message_count <= 1) and automated (is_automated=1) sessions
|
|
// MUST appear in the report. A refactor flipping those flags to match
|
|
// the analytics defaults would drop these sessions and fail here.
|
|
func TestActivityReportEndpoint_IncludesOneShotAndAutomated(t *testing.T) {
|
|
|
|
te := setup(t)
|
|
|
|
// One-shot: a single user message (user_message_count = 1).
|
|
started, ended := activityDate+"T11:00:00Z", activityDate+"T11:04:00Z"
|
|
te.seedSession(t, "oneshot", "alpha", 2, func(s *db.Session) {
|
|
s.Agent = "claude"
|
|
s.StartedAt = &started
|
|
s.EndedAt = &ended
|
|
s.UserMessageCount = 1
|
|
})
|
|
te.seedMessages(t, "oneshot", 2, func(i int, m *db.Message) {
|
|
m.Timestamp = []string{
|
|
activityDate + "T11:00:00Z", activityDate + "T11:02:00Z",
|
|
}[i]
|
|
})
|
|
|
|
// Automated: a first message matching a known automated (roborev)
|
|
// prompt prefix, which UpsertSession turns into is_automated = 1.
|
|
autoStart, autoEnd := activityDate+"T12:00:00Z", activityDate+"T12:04:00Z"
|
|
te.seedSession(t, "automated", "beta", 2, func(s *db.Session) {
|
|
s.Agent = "codex"
|
|
s.StartedAt = &autoStart
|
|
s.EndedAt = &autoEnd
|
|
s.FirstMessage = new("You are a code reviewer.")
|
|
s.UserMessageCount = 1
|
|
})
|
|
te.seedMessages(t, "automated", 2, func(i int, m *db.Message) {
|
|
m.Timestamp = []string{
|
|
activityDate + "T12:00:00Z", activityDate + "T12:02:00Z",
|
|
}[i]
|
|
})
|
|
|
|
w := te.get(t, buildPathURL("/api/v1/activity/report", map[string]string{
|
|
"preset": "day", "date": activityDate, "timezone": "UTC",
|
|
}))
|
|
assertStatus(t, w, http.StatusOK)
|
|
|
|
resp := decode[activity.Report](t, w)
|
|
ids := make(map[string]struct{}, len(resp.BySession))
|
|
for _, s := range resp.BySession {
|
|
ids[s.SessionID] = struct{}{}
|
|
}
|
|
assert.Contains(t, ids, "oneshot",
|
|
"one-shot session must be included (ExcludeOneShot=false)")
|
|
assert.Contains(t, ids, "automated",
|
|
"automated session must be included (ExcludeAutomated=false)")
|
|
assert.Equal(t, 2, resp.Totals.Sessions,
|
|
"both one-shot and automated sessions count toward the total")
|
|
}
|
|
|
|
func TestActivityReportEndpoint_Validation(t *testing.T) {
|
|
|
|
te := setup(t)
|
|
|
|
ts := activityDate + "T00:00:00Z"
|
|
tests := []struct {
|
|
name string
|
|
params map[string]string
|
|
}{
|
|
{
|
|
name: "bad date",
|
|
params: map[string]string{
|
|
"preset": "day", "date": "not-a-date", "timezone": "UTC",
|
|
},
|
|
},
|
|
{
|
|
name: "bad timezone",
|
|
params: map[string]string{
|
|
"preset": "day", "date": activityDate, "timezone": "Fake/Zone",
|
|
},
|
|
},
|
|
{
|
|
name: "custom missing bound",
|
|
params: map[string]string{
|
|
"preset": "custom", "from": activityDate + "T00:00:00Z",
|
|
},
|
|
},
|
|
{
|
|
name: "from after to",
|
|
params: map[string]string{
|
|
"preset": "custom",
|
|
"from": activityDate + "T12:00:00Z",
|
|
"to": activityDate + "T00:00:00Z",
|
|
},
|
|
},
|
|
{
|
|
name: "zero length range",
|
|
params: map[string]string{
|
|
"preset": "custom", "from": ts, "to": ts,
|
|
},
|
|
},
|
|
{
|
|
name: "range exceeds year",
|
|
params: map[string]string{
|
|
"preset": "custom",
|
|
"from": "2026-01-01T00:00:00Z",
|
|
"to": "2027-01-02T00:00:00Z",
|
|
},
|
|
},
|
|
{
|
|
name: "bucket count cap",
|
|
params: map[string]string{
|
|
"preset": "custom",
|
|
"from": "2026-01-01T00:00:00Z",
|
|
"to": "2026-12-31T00:00:00Z",
|
|
"bucket": "5m",
|
|
},
|
|
},
|
|
{
|
|
name: "bad automation",
|
|
params: map[string]string{
|
|
"preset": "day", "date": activityDate, "timezone": "UTC",
|
|
"automation": "bogus",
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
w := te.get(t, buildPathURL("/api/v1/activity/report", tc.params))
|
|
assertStatus(t, w, http.StatusBadRequest)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestActivityReportEndpoint_AutomationFilter confirms the activity endpoint's
|
|
// automation query param selects the requested class end to end: the default
|
|
// and "all" keep both classes, "interactive" drops automated sessions, and
|
|
// "automated" drops interactive ones. It also confirms the response Totals
|
|
// carry the automated/interactive session-count split.
|
|
func TestActivityReportEndpoint_AutomationFilter(t *testing.T) {
|
|
|
|
te := setup(t)
|
|
|
|
// Automated: a single-turn session whose first message matches a known
|
|
// automated (roborev) prompt prefix, which the classifier turns into
|
|
// is_automated = 1. UserMessageCount = 1 satisfies the single-turn gate.
|
|
autoStart, autoEnd := activityDate+"T12:00:00Z", activityDate+"T12:04:00Z"
|
|
te.seedSession(t, "automated", "beta", 2, func(s *db.Session) {
|
|
s.Agent = "codex"
|
|
s.StartedAt = &autoStart
|
|
s.EndedAt = &autoEnd
|
|
s.FirstMessage = new("You are a code reviewer.")
|
|
s.UserMessageCount = 1
|
|
})
|
|
te.seedMessages(t, "automated", 2, func(i int, m *db.Message) {
|
|
if i == 0 {
|
|
m.Content = "You are a code reviewer."
|
|
}
|
|
m.Timestamp = []string{
|
|
activityDate + "T12:00:00Z", activityDate + "T12:02:00Z",
|
|
}[i]
|
|
})
|
|
|
|
humanStart, humanEnd := activityDate+"T13:00:00Z", activityDate+"T13:04:00Z"
|
|
te.seedSession(t, "human", "alpha", 2, func(s *db.Session) {
|
|
s.Agent = "claude"
|
|
s.StartedAt = &humanStart
|
|
s.EndedAt = &humanEnd
|
|
})
|
|
te.seedMessages(t, "human", 2, func(i int, m *db.Message) {
|
|
m.Timestamp = []string{
|
|
activityDate + "T13:00:00Z", activityDate + "T13:02:00Z",
|
|
}[i]
|
|
})
|
|
|
|
tests := []struct {
|
|
name string
|
|
automation string
|
|
wantAutomated int
|
|
wantInteractive int
|
|
wantIDs []string
|
|
}{
|
|
{"default keeps both", "", 1, 1, []string{"automated", "human"}},
|
|
{"all keeps both", "all", 1, 1, []string{"automated", "human"}},
|
|
{"interactive drops automated", "interactive", 0, 1, []string{"human"}},
|
|
{"automated drops interactive", "automated", 1, 0, []string{"automated"}},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
params := map[string]string{
|
|
"preset": "day", "date": activityDate, "timezone": "UTC",
|
|
}
|
|
if tc.automation != "" {
|
|
params["automation"] = tc.automation
|
|
}
|
|
w := te.get(t, buildPathURL("/api/v1/activity/report", params))
|
|
assertStatus(t, w, http.StatusOK)
|
|
resp := decode[activity.Report](t, w)
|
|
assert.Equal(t, len(tc.wantIDs), resp.Totals.Sessions)
|
|
assert.Equal(t, tc.wantAutomated, resp.Totals.AutomatedSessions)
|
|
assert.Equal(t, tc.wantInteractive, resp.Totals.InteractiveSessions)
|
|
ids := make(map[string]struct{}, len(resp.BySession))
|
|
for _, s := range resp.BySession {
|
|
ids[s.SessionID] = struct{}{}
|
|
}
|
|
assert.Len(t, ids, len(tc.wantIDs))
|
|
for _, id := range tc.wantIDs {
|
|
assert.Contains(t, ids, id)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestActivityReportEndpoint_GitBranchFilter guards that /activity/report honors
|
|
// the git_branch filter (it previously ignored the param).
|
|
func TestActivityReportEndpoint_GitBranchFilter(t *testing.T) {
|
|
te := setup(t)
|
|
seed := []struct {
|
|
id, branch, started, ended string
|
|
times []string
|
|
}{
|
|
{"b1", "main", activityDate + "T10:00:00Z", activityDate + "T10:08:00Z",
|
|
[]string{activityDate + "T10:00:00Z", activityDate + "T10:02:00Z",
|
|
activityDate + "T10:05:00Z", activityDate + "T10:07:00Z"}},
|
|
{"b2", "feature-x", activityDate + "T10:01:00Z", activityDate + "T10:09:00Z",
|
|
[]string{activityDate + "T10:01:00Z", activityDate + "T10:03:00Z",
|
|
activityDate + "T10:06:00Z", activityDate + "T10:08:00Z"}},
|
|
}
|
|
for _, e := range seed {
|
|
started, ended, branch := e.started, e.ended, e.branch
|
|
te.seedSession(t, e.id, "alpha", len(e.times), func(s *db.Session) {
|
|
s.GitBranch = branch
|
|
s.StartedAt = &started
|
|
s.EndedAt = &ended
|
|
})
|
|
times := e.times
|
|
te.seedMessages(t, e.id, len(times), func(i int, m *db.Message) {
|
|
m.Timestamp = times[i]
|
|
})
|
|
}
|
|
|
|
all := te.get(t, buildPathURL("/api/v1/activity/report", map[string]string{
|
|
"preset": "day", "date": activityDate, "timezone": "UTC",
|
|
}))
|
|
assertStatus(t, all, http.StatusOK)
|
|
assert.Equal(t, 2, decode[activity.Report](t, all).Totals.Sessions)
|
|
|
|
filtered := te.get(t, buildPathURL("/api/v1/activity/report", map[string]string{
|
|
"preset": "day", "date": activityDate, "timezone": "UTC",
|
|
"git_branch": db.EncodeBranchFilterToken("alpha", "main"),
|
|
}))
|
|
assertStatus(t, filtered, http.StatusOK)
|
|
assert.Equal(t, 1, decode[activity.Report](t, filtered).Totals.Sessions,
|
|
"git_branch filter restricts the activity report to alpha/main")
|
|
}
|