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
779 lines
29 KiB
Go
779 lines
29 KiB
Go
package db
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
|
|
sqlite3 "github.com/mattn/go-sqlite3"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"go.kenn.io/agentsview/internal/activity"
|
|
)
|
|
|
|
func reportSessionIDs(sessions []activity.SessionRow) map[string]struct{} {
|
|
out := make(map[string]struct{}, len(sessions))
|
|
for _, s := range sessions {
|
|
out[s.SessionID] = struct{}{}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// dayQuery resolves a single-day "day" Query for date/tz against a fixed
|
|
// far-future now, so the candidate range is the full local day and the
|
|
// report is never partial regardless of the wall clock.
|
|
func dayQuery(t *testing.T, date, tz string) activity.Query {
|
|
t.Helper()
|
|
now, err := time.Parse(time.RFC3339, "2030-01-01T00:00:00Z")
|
|
require.NoError(t, err)
|
|
q, err := activity.ResolveQuery(
|
|
activity.QueryInput{Preset: "day", Date: date, Timezone: tz}, now)
|
|
require.NoError(t, err)
|
|
return q
|
|
}
|
|
|
|
func seedMessage(
|
|
t *testing.T, d *DB, sid string, ordinal int, role, ts, model string,
|
|
) {
|
|
t.Helper()
|
|
insertMessages(t, d, Message{
|
|
SessionID: sid,
|
|
Ordinal: ordinal,
|
|
Role: role,
|
|
Content: "x",
|
|
Timestamp: ts,
|
|
Model: model,
|
|
})
|
|
}
|
|
|
|
func TestGetActivityReport_BasicConcurrency(t *testing.T) {
|
|
d := testDB(t)
|
|
ctx := context.Background()
|
|
// Two overlapping sessions on 2026-06-16 (UTC), each two messages.
|
|
// started_at/ended_at are set explicitly so the candidate-session
|
|
// window anchors on the target day regardless of the wall clock; a
|
|
// created_at fallback would drift to the prior/next day when the
|
|
// suite runs near UTC midnight.
|
|
insertSession(t, d, "a", "proj1", func(s *Session) {
|
|
s.Agent = "claude"
|
|
s.StartedAt = Ptr("2026-06-16T10:00:00Z")
|
|
s.EndedAt = Ptr("2026-06-16T10:02:00Z")
|
|
})
|
|
seedMessage(t, d, "a", 1, "user", "2026-06-16T10:00:00Z", "")
|
|
seedMessage(t, d, "a", 2, "assistant", "2026-06-16T10:02:00Z", "opus")
|
|
insertSession(t, d, "b", "proj2", func(s *Session) {
|
|
s.Agent = "codex"
|
|
s.StartedAt = Ptr("2026-06-16T10:01:00Z")
|
|
s.EndedAt = Ptr("2026-06-16T10:03:00Z")
|
|
})
|
|
seedMessage(t, d, "b", 1, "user", "2026-06-16T10:01:00Z", "")
|
|
seedMessage(t, d, "b", 2, "assistant", "2026-06-16T10:03:00Z", "gpt5")
|
|
|
|
r, err := d.GetActivityReport(ctx, AnalyticsFilter{Timezone: "UTC"},
|
|
dayQuery(t, "2026-06-16", "UTC"))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 2, r.Peak.Agents)
|
|
assert.Equal(t, 2, r.Totals.Sessions)
|
|
assert.GreaterOrEqual(t, len(r.ByModel), 2)
|
|
}
|
|
|
|
func TestActivityReportEmptyProjectsMapExcludesUnrelatedObservations(t *testing.T) {
|
|
d := testDB(t)
|
|
ctx := context.Background()
|
|
seedProjectIdentityObservation(t, d, "unrelated-project")
|
|
|
|
r, err := d.GetActivityReport(ctx, AnalyticsFilter{Timezone: "UTC"},
|
|
dayQuery(t, "2026-06-16", "UTC"))
|
|
require.NoError(t, err)
|
|
assert.Empty(t, r.BySession)
|
|
assert.Empty(t, r.Projects)
|
|
}
|
|
|
|
func TestGetActivityReport_UsageCostAndTokens(t *testing.T) {
|
|
d := testDB(t)
|
|
ctx := context.Background()
|
|
require.NoError(t, d.UpsertModelPricing([]ModelPricing{{
|
|
ModelPattern: "claude-sonnet-4-20250514",
|
|
InputPerMTok: 3.0,
|
|
OutputPerMTok: 15.0,
|
|
}}), "UpsertModelPricing")
|
|
|
|
insertSession(t, d, "s1", "proj1", func(s *Session) {
|
|
s.Agent = "claude"
|
|
s.StartedAt = Ptr("2026-06-16T10:30:00Z")
|
|
s.EndedAt = Ptr("2026-06-16T10:30:00Z")
|
|
})
|
|
insertMessages(t, d, Message{
|
|
SessionID: "s1",
|
|
Ordinal: 0,
|
|
Role: "assistant",
|
|
Content: "x",
|
|
Timestamp: "2026-06-16T10:30:00Z",
|
|
Model: "claude-sonnet-4-20250514",
|
|
TokenUsage: json.RawMessage(`{"input_tokens":1000,"output_tokens":500}`),
|
|
})
|
|
|
|
r, err := d.GetActivityReport(ctx, AnalyticsFilter{Timezone: "UTC"},
|
|
dayQuery(t, "2026-06-16", "UTC"))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 1, r.Totals.Sessions)
|
|
assert.Equal(t, 500, r.Totals.OutputTokens)
|
|
// Cost = (1000*3 + 500*15) / 1e6 = 0.0105
|
|
assert.InDelta(t, 0.0105, r.Totals.Cost, 1e-9)
|
|
}
|
|
|
|
func TestGetActivityReport_PricingModelsOnlyIncludeDedupSurvivors(t *testing.T) {
|
|
d := testDB(t)
|
|
ctx := context.Background()
|
|
require.NoError(t, d.UpsertModelPricing([]ModelPricing{
|
|
{
|
|
ModelPattern: "kept-model",
|
|
InputPerMTok: 3.0,
|
|
OutputPerMTok: 15.0,
|
|
},
|
|
{
|
|
ModelPattern: "discarded-model",
|
|
InputPerMTok: 3.0,
|
|
OutputPerMTok: 15.0,
|
|
},
|
|
}), "UpsertModelPricing")
|
|
|
|
insertSession(t, d, "earlier", "proj1", func(s *Session) {
|
|
s.Agent = "claude"
|
|
s.StartedAt = Ptr("2026-06-16T10:30:00Z")
|
|
s.EndedAt = Ptr("2026-06-16T10:30:00Z")
|
|
})
|
|
insertMessages(t, d, Message{
|
|
SessionID: "earlier", Ordinal: 0, Role: "assistant", Content: "x",
|
|
Timestamp: "2026-06-16T10:30:00Z",
|
|
Model: "kept-model",
|
|
ClaudeMessageID: "m-dup", ClaudeRequestID: "r-dup",
|
|
TokenUsage: json.RawMessage(`{"input_tokens":1000,"output_tokens":500}`),
|
|
})
|
|
insertSession(t, d, "later", "proj1", func(s *Session) {
|
|
s.Agent = "claude"
|
|
s.StartedAt = Ptr("2026-06-16T10:31:00Z")
|
|
s.EndedAt = Ptr("2026-06-16T10:31:00Z")
|
|
})
|
|
insertMessages(t, d, Message{
|
|
SessionID: "later", Ordinal: 0, Role: "assistant", Content: "x",
|
|
Timestamp: "2026-06-16T10:31:00Z",
|
|
Model: "discarded-model",
|
|
ClaudeMessageID: "m-dup", ClaudeRequestID: "r-dup",
|
|
TokenUsage: json.RawMessage(`{"input_tokens":2000,"output_tokens":900}`),
|
|
})
|
|
|
|
r, err := d.GetActivityReport(ctx, AnalyticsFilter{Timezone: "UTC"},
|
|
dayQuery(t, "2026-06-16", "UTC"))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 500, r.Totals.OutputTokens)
|
|
require.NotNil(t, r.Pricing)
|
|
assert.Contains(t, r.Pricing.Models, "kept-model")
|
|
assert.NotContains(t, r.Pricing.Models, "discarded-model")
|
|
}
|
|
|
|
// TestGetActivityReport_IncludesSubagentUsage confirms subagent and fork
|
|
// sessions are candidates so their usage lands in the totals, keeping the
|
|
// activity cost consistent with GetDailyUsage (which never filters by
|
|
// relationship_type). A fork whose only usage row replays the root's
|
|
// Claude ids contributes a session row but no extra cost: the aggregator's
|
|
// first-seen dedup collapses the duplicate, the same guarantee
|
|
// GetDailyUsage relies on.
|
|
func TestGetActivityReport_IncludesSubagentUsage(t *testing.T) {
|
|
d := testDB(t)
|
|
ctx := context.Background()
|
|
require.NoError(t, d.UpsertModelPricing([]ModelPricing{
|
|
{ModelPattern: "root-model", InputPerMTok: 3.0, OutputPerMTok: 15.0},
|
|
{ModelPattern: "sub-model", InputPerMTok: 3.0, OutputPerMTok: 15.0},
|
|
}), "UpsertModelPricing")
|
|
|
|
insertSession(t, d, "root", "proj1", func(s *Session) {
|
|
s.Agent = "claude"
|
|
s.StartedAt = Ptr("2026-06-16T10:00:00Z")
|
|
s.EndedAt = Ptr("2026-06-16T10:10:00Z")
|
|
})
|
|
insertMessages(t, d, Message{
|
|
SessionID: "root", Ordinal: 0, Role: "assistant", Content: "x",
|
|
Timestamp: "2026-06-16T10:00:00Z", Model: "root-model",
|
|
ClaudeMessageID: "m-root", ClaudeRequestID: "r-root",
|
|
TokenUsage: json.RawMessage(`{"input_tokens":1000,"output_tokens":500}`),
|
|
})
|
|
insertSession(t, d, "agent-sub", "proj1", func(s *Session) {
|
|
s.Agent = "claude"
|
|
s.ParentSessionID = Ptr("root")
|
|
s.RelationshipType = "subagent"
|
|
s.StartedAt = Ptr("2026-06-16T10:02:00Z")
|
|
s.EndedAt = Ptr("2026-06-16T10:04:00Z")
|
|
})
|
|
insertMessages(t, d, Message{
|
|
SessionID: "agent-sub", Ordinal: 0, Role: "assistant", Content: "y",
|
|
Timestamp: "2026-06-16T10:03:00Z", Model: "sub-model",
|
|
ClaudeMessageID: "m-sub", ClaudeRequestID: "r-sub",
|
|
TokenUsage: json.RawMessage(`{"input_tokens":2000,"output_tokens":700}`),
|
|
})
|
|
// Fork replaying the root's message: same Claude ids, so the dedup
|
|
// must drop its usage row while the session itself still appears.
|
|
insertSession(t, d, "fork", "proj1", func(s *Session) {
|
|
s.Agent = "claude"
|
|
s.ParentSessionID = Ptr("root")
|
|
s.RelationshipType = "fork"
|
|
s.StartedAt = Ptr("2026-06-16T10:05:00Z")
|
|
s.EndedAt = Ptr("2026-06-16T10:06:00Z")
|
|
})
|
|
insertMessages(t, d, Message{
|
|
SessionID: "fork", Ordinal: 0, Role: "assistant", Content: "x",
|
|
Timestamp: "2026-06-16T10:05:00Z", Model: "root-model",
|
|
ClaudeMessageID: "m-root", ClaudeRequestID: "r-root",
|
|
TokenUsage: json.RawMessage(`{"input_tokens":1000,"output_tokens":500}`),
|
|
})
|
|
|
|
r, err := d.GetActivityReport(ctx, AnalyticsFilter{Timezone: "UTC"},
|
|
dayQuery(t, "2026-06-16", "UTC"))
|
|
require.NoError(t, err)
|
|
ids := reportSessionIDs(r.BySession)
|
|
assert.Contains(t, ids, "root")
|
|
assert.Contains(t, ids, "agent-sub",
|
|
"subagent session must be a candidate")
|
|
assert.Contains(t, ids, "fork", "fork session must be a candidate")
|
|
assert.Equal(t, 1200, r.Totals.OutputTokens,
|
|
"totals include subagent usage; the fork's replayed row dedups away")
|
|
// Cost = root (1000*3+500*15)/1e6 + subagent (2000*3+700*15)/1e6; the
|
|
// fork's duplicate row contributes nothing.
|
|
assert.InDelta(t, 0.0105+0.0165, r.Totals.Cost, 1e-9)
|
|
}
|
|
|
|
// TestGetActivityReport_ExcludesOtherDays confirms the candidate-session
|
|
// window and the usage ts-bounds keep a session whose only activity
|
|
// falls outside the target day from contributing to that day.
|
|
func TestGetActivityReport_ExcludesOtherDays(t *testing.T) {
|
|
d := testDB(t)
|
|
ctx := context.Background()
|
|
|
|
insertSession(t, d, "today", "proj1", func(s *Session) {
|
|
s.Agent = "claude"
|
|
s.StartedAt = Ptr("2026-06-16T10:00:00Z")
|
|
s.EndedAt = Ptr("2026-06-16T10:02:00Z")
|
|
})
|
|
seedMessage(t, d, "today", 1, "user", "2026-06-16T10:00:00Z", "")
|
|
seedMessage(t, d, "today", 2, "assistant", "2026-06-16T10:02:00Z", "opus")
|
|
|
|
insertSession(t, d, "yesterday", "proj2", func(s *Session) {
|
|
s.Agent = "codex"
|
|
s.StartedAt = Ptr("2026-06-10T10:00:00Z")
|
|
s.EndedAt = Ptr("2026-06-10T10:02:00Z")
|
|
})
|
|
seedMessage(t, d, "yesterday", 1, "user", "2026-06-10T10:00:00Z", "")
|
|
seedMessage(t, d, "yesterday", 2, "assistant", "2026-06-10T10:02:00Z", "gpt5")
|
|
|
|
r, err := d.GetActivityReport(ctx, AnalyticsFilter{Timezone: "UTC"},
|
|
dayQuery(t, "2026-06-16", "UTC"))
|
|
require.NoError(t, err)
|
|
// Only the in-day session has timed intervals on 2026-06-16.
|
|
assert.Equal(t, 1, r.Peak.Agents)
|
|
require.Len(t, r.ByAgent, 1)
|
|
assert.Equal(t, "claude", r.ByAgent[0].Key)
|
|
}
|
|
|
|
// TestGetActivityReport_PriorDayWithinPadExcluded confirms the candidate
|
|
// window uses the EXACT local day, not the +/-14h padded bounds: a
|
|
// session that began and ended on the prior day but lands inside the
|
|
// pad must NOT appear as an untimed session in the target day's report.
|
|
func TestGetActivityReport_PriorDayWithinPadExcluded(t *testing.T) {
|
|
d := testDB(t)
|
|
ctx := context.Background()
|
|
|
|
insertSession(t, d, "today", "proj1", func(s *Session) {
|
|
s.Agent = "claude"
|
|
s.StartedAt = Ptr("2026-06-16T10:00:00Z")
|
|
s.EndedAt = Ptr("2026-06-16T10:02:00Z")
|
|
})
|
|
seedMessage(t, d, "today", 1, "user", "2026-06-16T10:00:00Z", "")
|
|
seedMessage(t, d, "today", 2, "assistant", "2026-06-16T10:02:00Z", "opus")
|
|
|
|
// Prior-day session at 2026-06-15T12:00Z: within the old -14h pad
|
|
// (2026-06-15T10:00Z) but outside the exact 2026-06-16 UTC window.
|
|
insertSession(t, d, "prior", "proj2", func(s *Session) {
|
|
s.Agent = "codex"
|
|
s.StartedAt = Ptr("2026-06-15T12:00:00Z")
|
|
s.EndedAt = Ptr("2026-06-15T12:05:00Z")
|
|
})
|
|
|
|
r, err := d.GetActivityReport(ctx, AnalyticsFilter{Timezone: "UTC"},
|
|
dayQuery(t, "2026-06-16", "UTC"))
|
|
require.NoError(t, err)
|
|
ids := reportSessionIDs(r.BySession)
|
|
assert.Contains(t, ids, "today")
|
|
assert.NotContains(t, ids, "prior", "prior-day session must not leak in")
|
|
assert.Equal(t, 1, r.Totals.Sessions)
|
|
assert.Equal(t, 0, r.Totals.UntimedSessions)
|
|
}
|
|
|
|
// TestGetActivityReport_UntimedSessionOnDayIncluded confirms a session
|
|
// that started on the target day but has no timestamped messages still
|
|
// appears in the report as an untimed candidate.
|
|
func TestGetActivityReport_UntimedSessionOnDayIncluded(t *testing.T) {
|
|
d := testDB(t)
|
|
ctx := context.Background()
|
|
|
|
insertSession(t, d, "untimed", "proj1", func(s *Session) {
|
|
s.Agent = "claude"
|
|
s.StartedAt = Ptr("2026-06-16T09:00:00Z")
|
|
})
|
|
|
|
r, err := d.GetActivityReport(ctx, AnalyticsFilter{Timezone: "UTC"},
|
|
dayQuery(t, "2026-06-16", "UTC"))
|
|
require.NoError(t, err)
|
|
ids := reportSessionIDs(r.BySession)
|
|
assert.Contains(t, ids, "untimed")
|
|
assert.Equal(t, 1, r.Totals.UntimedSessions)
|
|
}
|
|
|
|
// TestGetActivityReport_EmptyStringEndedAtIncluded confirms the overlap
|
|
// predicate uses NULLIF so a session with an empty-string ended_at but a
|
|
// valid started_at on the target day is not excluded by COALESCE
|
|
// treating an empty string as a real upper bound.
|
|
func TestGetActivityReport_EmptyStringEndedAtIncluded(t *testing.T) {
|
|
d := testDB(t)
|
|
ctx := context.Background()
|
|
|
|
insertSession(t, d, "empty-end", "proj1", func(s *Session) {
|
|
s.Agent = "claude"
|
|
s.StartedAt = Ptr("2026-06-16T09:00:00Z")
|
|
s.EndedAt = Ptr("")
|
|
})
|
|
|
|
r, err := d.GetActivityReport(ctx, AnalyticsFilter{Timezone: "UTC"},
|
|
dayQuery(t, "2026-06-16", "UTC"))
|
|
require.NoError(t, err)
|
|
ids := reportSessionIDs(r.BySession)
|
|
assert.Contains(t, ids, "empty-end", "empty ended_at must fall back to started_at")
|
|
}
|
|
|
|
// TestGetActivityReport_SubSecondDayStartIncluded confirms a session whose
|
|
// only activity lands in the first sub-second of the day is not dropped by
|
|
// SQLite's lexicographic TEXT comparison. A stored RFC3339Nano value like
|
|
// "2026-06-14T00:00:00.123Z" sorts before a Z-suffixed day-start bound
|
|
// ("2026-06-14T00:00:00Z") because '.' < 'Z', so a Z-suffixed bound would
|
|
// wrongly exclude it. The zone-less day bound fixes that.
|
|
func TestGetActivityReport_SubSecondDayStartIncluded(t *testing.T) {
|
|
d := testDB(t)
|
|
ctx := context.Background()
|
|
|
|
insertSession(t, d, "subsec", "proj1", func(s *Session) {
|
|
s.Agent = "claude"
|
|
s.StartedAt = Ptr("2026-06-14T00:00:00.123Z")
|
|
s.EndedAt = Ptr("2026-06-14T00:00:00.123Z")
|
|
})
|
|
seedMessage(t, d, "subsec", 0, "user", "2026-06-14T00:00:00.123Z", "")
|
|
seedMessage(t, d, "subsec", 1, "assistant", "2026-06-14T00:00:00.456Z", "opus")
|
|
|
|
r, err := d.GetActivityReport(ctx, AnalyticsFilter{Timezone: "UTC"},
|
|
dayQuery(t, "2026-06-14", "UTC"))
|
|
require.NoError(t, err)
|
|
ids := reportSessionIDs(r.BySession)
|
|
assert.Contains(t, ids, "subsec",
|
|
"first-sub-second session must not be dropped by the day-start bound")
|
|
assert.GreaterOrEqual(t, r.Totals.Sessions, 1)
|
|
}
|
|
|
|
// TestGetActivityReport_ExcludesIneligibleUsage confirms the usage union
|
|
// applies the same eligibility filters as GetDailyUsage: a message with
|
|
// an empty model and empty token_usage must not inflate the day totals.
|
|
func TestGetActivityReport_ExcludesIneligibleUsage(t *testing.T) {
|
|
d := testDB(t)
|
|
ctx := context.Background()
|
|
require.NoError(t, d.UpsertModelPricing([]ModelPricing{{
|
|
ModelPattern: "claude-sonnet-4-20250514",
|
|
InputPerMTok: 3.0,
|
|
OutputPerMTok: 15.0,
|
|
}}), "UpsertModelPricing")
|
|
|
|
insertSession(t, d, "s1", "proj1", func(s *Session) {
|
|
s.Agent = "claude"
|
|
s.StartedAt = Ptr("2026-06-16T10:30:00Z")
|
|
s.EndedAt = Ptr("2026-06-16T10:31:00Z")
|
|
})
|
|
insertMessages(t, d, Message{
|
|
SessionID: "s1",
|
|
Ordinal: 0,
|
|
Role: "assistant",
|
|
Content: "x",
|
|
Timestamp: "2026-06-16T10:30:00Z",
|
|
Model: "claude-sonnet-4-20250514",
|
|
TokenUsage: json.RawMessage(`{"input_tokens":1000,"output_tokens":500}`),
|
|
})
|
|
// Ineligible: a synthetic-model message carrying real token_usage.
|
|
// usageMessageEligibility drops m.model == '<synthetic>', so these
|
|
// tokens must NOT leak into the day totals even though the blob is
|
|
// non-empty.
|
|
insertMessages(t, d, Message{
|
|
SessionID: "s1",
|
|
Ordinal: 1,
|
|
Role: "assistant",
|
|
Content: "y",
|
|
Timestamp: "2026-06-16T10:31:00Z",
|
|
Model: "<synthetic>",
|
|
TokenUsage: json.RawMessage(`{"input_tokens":9000,"output_tokens":7000}`),
|
|
})
|
|
|
|
r, err := d.GetActivityReport(ctx, AnalyticsFilter{Timezone: "UTC"},
|
|
dayQuery(t, "2026-06-16", "UTC"))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 500, r.Totals.OutputTokens, "synthetic message excluded")
|
|
assert.InDelta(t, 0.0105, r.Totals.Cost, 1e-9)
|
|
}
|
|
|
|
// TestGetActivityReport_HourlyRange exercises a multi-day custom range so
|
|
// the bucket auto-policy selects hourly buckets, and confirms the fetch
|
|
// window spans the whole range: a session whose only activity falls on the
|
|
// middle day populates the hourly bucket that contains it.
|
|
func TestGetActivityReport_HourlyRange(t *testing.T) {
|
|
d := testDB(t)
|
|
ctx := context.Background()
|
|
|
|
insertSession(t, d, "mid", "proj1", func(s *Session) {
|
|
s.Agent = "claude"
|
|
s.StartedAt = Ptr("2026-06-17T10:00:00Z")
|
|
s.EndedAt = Ptr("2026-06-17T10:30:00Z")
|
|
})
|
|
seedMessage(t, d, "mid", 1, "user", "2026-06-17T10:00:00Z", "")
|
|
seedMessage(t, d, "mid", 2, "assistant", "2026-06-17T10:30:00Z", "opus")
|
|
|
|
// 3-day span -> hourly buckets per the auto policy.
|
|
now, err := time.Parse(time.RFC3339, "2030-01-01T00:00:00Z")
|
|
require.NoError(t, err)
|
|
q, err := activity.ResolveQuery(activity.QueryInput{
|
|
Preset: "custom", Timezone: "UTC",
|
|
From: "2026-06-16T00:00:00Z", To: "2026-06-19T00:00:00Z",
|
|
}, now)
|
|
require.NoError(t, err)
|
|
|
|
r, err := d.GetActivityReport(ctx, AnalyticsFilter{Timezone: "UTC"}, q)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "hour", r.BucketUnit)
|
|
assert.Equal(t, 72, r.BucketCount, "3 days of hourly buckets")
|
|
// The 30-min gap caps to 5 min and lands in the 2026-06-17T10:00 bucket.
|
|
var found bool
|
|
for _, b := range r.Buckets {
|
|
if b.Start == "2026-06-17T10:00:00Z" {
|
|
found = true
|
|
assert.Equal(t, "2026-06-17T11:00:00Z", b.End)
|
|
assert.InDelta(t, 5.0, b.AgentMinutes, 1e-9,
|
|
"mid-range hourly bucket is populated")
|
|
}
|
|
}
|
|
assert.True(t, found, "the 2026-06-17T10:00 hourly bucket must be present")
|
|
}
|
|
|
|
// TestGetActivityReport_UsageDedupSubSecondOrder confirms the SQLite usage
|
|
// stream is ordered by the PARSED instant, not the RFC3339 text. A
|
|
// resumed/forked pair shares one (claude_message_id, claude_request_id) dedup
|
|
// key in the same second: one whole-second instant ("...00Z", 500 output
|
|
// tokens) and one fractional ("...00.123Z", 9000). Lexically "...00.123Z"
|
|
// sorts before "...00Z" ('.' < 'Z'), so a TEXT sort would keep the 9000 row;
|
|
// chronologically the whole-second row is first. First-seen-wins dedup must
|
|
// keep the 500 row, matching PostgreSQL/DuckDB which order on the parsed time.
|
|
func TestGetActivityReport_UsageDedupSubSecondOrder(t *testing.T) {
|
|
d := testDB(t)
|
|
ctx := context.Background()
|
|
require.NoError(t, d.UpsertModelPricing([]ModelPricing{{
|
|
ModelPattern: "claude-sonnet-4-20250514",
|
|
InputPerMTok: 3.0,
|
|
OutputPerMTok: 15.0,
|
|
}}), "UpsertModelPricing")
|
|
|
|
insertSession(t, d, "earlier", "proj1", func(s *Session) {
|
|
s.Agent = "claude"
|
|
s.StartedAt = Ptr("2026-06-16T10:30:00Z")
|
|
s.EndedAt = Ptr("2026-06-16T10:30:00Z")
|
|
})
|
|
insertMessages(t, d, Message{
|
|
SessionID: "earlier", Ordinal: 0, Role: "assistant", Content: "x",
|
|
Timestamp: "2026-06-16T10:30:00Z",
|
|
Model: "claude-sonnet-4-20250514",
|
|
ClaudeMessageID: "m-dup", ClaudeRequestID: "r-dup",
|
|
TokenUsage: json.RawMessage(`{"input_tokens":1000,"output_tokens":500}`),
|
|
})
|
|
insertSession(t, d, "later", "proj2", func(s *Session) {
|
|
s.Agent = "claude"
|
|
s.StartedAt = Ptr("2026-06-16T10:30:00Z")
|
|
s.EndedAt = Ptr("2026-06-16T10:30:00Z")
|
|
})
|
|
insertMessages(t, d, Message{
|
|
SessionID: "later", Ordinal: 0, Role: "assistant", Content: "x",
|
|
Timestamp: "2026-06-16T10:30:00.123Z",
|
|
Model: "claude-sonnet-4-20250514",
|
|
ClaudeMessageID: "m-dup", ClaudeRequestID: "r-dup",
|
|
TokenUsage: json.RawMessage(`{"input_tokens":1000,"output_tokens":9000}`),
|
|
})
|
|
|
|
r, err := d.GetActivityReport(ctx, AnalyticsFilter{Timezone: "UTC"},
|
|
dayQuery(t, "2026-06-16", "UTC"))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 500, r.Totals.OutputTokens,
|
|
"first-seen dedup keeps the chronologically earlier whole-second row")
|
|
}
|
|
|
|
func TestGetActivityReport_UsageDedupFallsBackToSourceUUID(t *testing.T) {
|
|
d := testDB(t)
|
|
ctx := context.Background()
|
|
require.NoError(t, d.UpsertModelPricing([]ModelPricing{{
|
|
ModelPattern: "claude-sonnet-4-20250514",
|
|
InputPerMTok: 3.0,
|
|
OutputPerMTok: 15.0,
|
|
}}), "UpsertModelPricing")
|
|
|
|
insertSession(t, d, "earlier", "proj1", func(s *Session) {
|
|
s.Agent = "claude"
|
|
s.StartedAt = Ptr("2026-06-16T10:30:00Z")
|
|
s.EndedAt = Ptr("2026-06-16T10:30:00Z")
|
|
})
|
|
insertMessages(t, d, Message{
|
|
SessionID: "earlier",
|
|
Ordinal: 0,
|
|
Role: "assistant",
|
|
Content: "x",
|
|
Timestamp: "2026-06-16T10:30:00Z",
|
|
Model: "claude-sonnet-4-20250514",
|
|
ClaudeMessageID: "m-dup",
|
|
ClaudeRequestID: "",
|
|
SourceUUID: "src-dup",
|
|
TokenUsage: json.RawMessage(`{"input_tokens":1000,"output_tokens":500}`),
|
|
})
|
|
insertSession(t, d, "later", "proj2", func(s *Session) {
|
|
s.Agent = "claude"
|
|
s.StartedAt = Ptr("2026-06-16T10:30:01Z")
|
|
s.EndedAt = Ptr("2026-06-16T10:30:01Z")
|
|
})
|
|
insertMessages(t, d, Message{
|
|
SessionID: "later",
|
|
Ordinal: 0,
|
|
Role: "assistant",
|
|
Content: "x",
|
|
Timestamp: "2026-06-16T10:30:01Z",
|
|
Model: "claude-sonnet-4-20250514",
|
|
ClaudeMessageID: "m-dup",
|
|
ClaudeRequestID: "",
|
|
SourceUUID: "src-dup",
|
|
TokenUsage: json.RawMessage(`{"input_tokens":1000,"output_tokens":900}`),
|
|
})
|
|
|
|
r, err := d.GetActivityReport(ctx, AnalyticsFilter{Timezone: "UTC"},
|
|
dayQuery(t, "2026-06-16", "UTC"))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 500, r.Totals.OutputTokens,
|
|
"incomplete Claude pairs fall back to source_uuid dedup in activity reports")
|
|
}
|
|
|
|
// TestGetActivityReport_TitleSkipsEmptyDisplayName confirms the Title fallback
|
|
// null-checks each candidate independently: an empty (non-NULL) display_name
|
|
// must not mask a real session_name. A nested COALESCE(display_name,
|
|
// session_name) would return an empty string and be NULLIF'd away, wrongly
|
|
// skipping to first_message. RenameSession stores a literal empty string (only
|
|
// nil clears to NULL), so this reproduces a session renamed to "" that still
|
|
// has a session_name.
|
|
func TestGetActivityReport_TitleSkipsEmptyDisplayName(t *testing.T) {
|
|
d := testDB(t)
|
|
ctx := context.Background()
|
|
insertSession(t, d, "s", "proj", func(s *Session) {
|
|
s.Agent = "claude"
|
|
s.SessionName = Ptr("real-session-name")
|
|
s.FirstMessage = Ptr("first message text")
|
|
s.StartedAt = Ptr("2026-06-16T10:00:00Z")
|
|
s.EndedAt = Ptr("2026-06-16T10:02:00Z")
|
|
})
|
|
require.NoError(t, d.RenameSession("s", Ptr("")))
|
|
seedMessage(t, d, "s", 1, "user", "2026-06-16T10:00:00Z", "")
|
|
seedMessage(t, d, "s", 2, "assistant", "2026-06-16T10:02:00Z", "opus")
|
|
|
|
r, err := d.GetActivityReport(ctx, AnalyticsFilter{Timezone: "UTC"},
|
|
dayQuery(t, "2026-06-16", "UTC"))
|
|
require.NoError(t, err)
|
|
require.Len(t, r.BySession, 1)
|
|
assert.Equal(t, "real-session-name", r.BySession[0].Title,
|
|
"empty display_name must not mask the real session_name")
|
|
}
|
|
|
|
func TestGetActivityReport_TitleNeverFallsBackToFirstMessage(t *testing.T) {
|
|
d := testDB(t)
|
|
ctx := context.Background()
|
|
insertSession(t, d, "private-title", "safe-project", func(s *Session) {
|
|
s.Agent = "claude"
|
|
s.FirstMessage = Ptr("distinctive private prompt sentinel")
|
|
s.StartedAt = Ptr("2026-06-16T10:00:00Z")
|
|
s.EndedAt = Ptr("2026-06-16T10:02:00Z")
|
|
})
|
|
seedMessage(t, d, "private-title", 1, "user", "2026-06-16T10:00:00Z", "")
|
|
seedMessage(t, d, "private-title", 2, "assistant", "2026-06-16T10:02:00Z", "opus")
|
|
|
|
r, err := d.GetActivityReport(ctx, AnalyticsFilter{Timezone: "UTC"},
|
|
dayQuery(t, "2026-06-16", "UTC"))
|
|
require.NoError(t, err)
|
|
require.Len(t, r.BySession, 1)
|
|
assert.Equal(t, "safe-project", r.BySession[0].Title)
|
|
assert.NotContains(t, r.BySession[0].Title, "private prompt sentinel")
|
|
}
|
|
|
|
// TestGetActivityReport_OpenSessionWithInRangeMessageIncluded confirms a
|
|
// still-open session (no ended_at) that started before the range but has a
|
|
// message inside it is not dropped. The effective-end fallback uses the
|
|
// session's latest message timestamp, not started_at, so the overlap
|
|
// predicate sees the in-range activity. Without the fix, ended_at falls back
|
|
// to the pre-range started_at and the session vanishes from the report.
|
|
func TestGetActivityReport_OpenSessionWithInRangeMessageIncluded(t *testing.T) {
|
|
d := testDB(t)
|
|
ctx := context.Background()
|
|
|
|
// Started the day before and never closed (no ended_at), active in-range.
|
|
insertSession(t, d, "open", "proj1", func(s *Session) {
|
|
s.Agent = "claude"
|
|
s.StartedAt = Ptr("2026-06-15T23:00:00Z")
|
|
})
|
|
seedMessage(t, d, "open", 1, "user", "2026-06-16T10:00:00Z", "")
|
|
seedMessage(t, d, "open", 2, "assistant", "2026-06-16T10:02:00Z", "opus")
|
|
|
|
r, err := d.GetActivityReport(ctx, AnalyticsFilter{Timezone: "UTC"},
|
|
dayQuery(t, "2026-06-16", "UTC"))
|
|
require.NoError(t, err)
|
|
ids := reportSessionIDs(r.BySession)
|
|
assert.Contains(t, ids, "open",
|
|
"open session active in-range must not be dropped by the started_at fallback")
|
|
assert.Equal(t, 1, r.Totals.Sessions)
|
|
}
|
|
|
|
// TestGetActivityReport_AutomationFilterAndSessionSplit confirms the
|
|
// AnalyticsFilter automation class selects the right sessions and that the
|
|
// Totals carry the automated/interactive session-count split. "all" keeps
|
|
// both classes; ExcludeAutomated keeps only interactive sessions;
|
|
// ExcludeInteractive (the mirror predicate) keeps only automated ones.
|
|
func TestGetActivityReport_AutomationFilterAndSessionSplit(t *testing.T) {
|
|
d := testDB(t)
|
|
ctx := context.Background()
|
|
|
|
// Two automated (roborev-style) sessions and one interactive, all timed
|
|
// on 2026-06-16 so each is a candidate for that day's report. Automated
|
|
// sessions are classified the way the sync path does it: a single-turn
|
|
// session whose first message matches a known automated prompt prefix.
|
|
for _, id := range []string{"auto1", "auto2"} {
|
|
start := "2026-06-16T10:00:00Z"
|
|
end := "2026-06-16T10:02:00Z"
|
|
insertSession(t, d, id, "proj1", func(s *Session) {
|
|
s.Agent = "claude"
|
|
s.FirstMessage = Ptr("You are a code reviewer.")
|
|
s.UserMessageCount = 1
|
|
s.StartedAt = Ptr(start)
|
|
s.EndedAt = Ptr(end)
|
|
})
|
|
seedMessage(t, d, id, 1, "user", start, "")
|
|
seedMessage(t, d, id, 2, "assistant", end, "opus")
|
|
}
|
|
insertSession(t, d, "human", "proj2", func(s *Session) {
|
|
s.Agent = "codex"
|
|
s.StartedAt = Ptr("2026-06-16T12:00:00Z")
|
|
s.EndedAt = Ptr("2026-06-16T12:02:00Z")
|
|
})
|
|
seedMessage(t, d, "human", 1, "user", "2026-06-16T12:00:00Z", "")
|
|
seedMessage(t, d, "human", 2, "assistant", "2026-06-16T12:02:00Z", "gpt5")
|
|
|
|
tests := []struct {
|
|
name string
|
|
filter AnalyticsFilter
|
|
wantAutomated int
|
|
wantInteractive int
|
|
wantIDs []string
|
|
}{
|
|
{
|
|
name: "all keeps both classes",
|
|
filter: AnalyticsFilter{Timezone: "UTC"},
|
|
wantAutomated: 2,
|
|
wantInteractive: 1,
|
|
wantIDs: []string{"auto1", "auto2", "human"},
|
|
},
|
|
{
|
|
name: "exclude automated keeps interactive only",
|
|
filter: AnalyticsFilter{Timezone: "UTC", ExcludeAutomated: true},
|
|
wantAutomated: 0,
|
|
wantInteractive: 1,
|
|
wantIDs: []string{"human"},
|
|
},
|
|
{
|
|
name: "exclude interactive keeps automated only",
|
|
filter: AnalyticsFilter{Timezone: "UTC", ExcludeInteractive: true},
|
|
wantAutomated: 2,
|
|
wantInteractive: 0,
|
|
wantIDs: []string{"auto1", "auto2"},
|
|
},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
r, err := d.GetActivityReport(ctx, tc.filter,
|
|
dayQuery(t, "2026-06-16", "UTC"))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, len(tc.wantIDs), r.Totals.Sessions)
|
|
assert.Equal(t, tc.wantAutomated, r.Totals.AutomatedSessions)
|
|
assert.Equal(t, tc.wantInteractive, r.Totals.InteractiveSessions)
|
|
ids := reportSessionIDs(r.BySession)
|
|
require.Len(t, ids, len(tc.wantIDs))
|
|
for _, id := range tc.wantIDs {
|
|
assert.Contains(t, ids, id)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// forceReaderVarLimit pins the reader pool to a single connection and lowers
|
|
// its SQLITE_LIMIT_VARIABLE_NUMBER to mimic older SQLite builds, whose limit is
|
|
// the documented 999 rather than the modern default (32766). Every read through
|
|
// d.getReader() then reuses this one constrained connection, so a query that
|
|
// binds too many variables fails exactly as it would on those builds.
|
|
func forceReaderVarLimit(t *testing.T, d *DB, limit int) {
|
|
t.Helper()
|
|
reader := d.rawReader()
|
|
reader.SetMaxOpenConns(1)
|
|
reader.SetMaxIdleConns(1)
|
|
conn, err := reader.Conn(context.Background())
|
|
require.NoError(t, err)
|
|
defer func() { require.NoError(t, conn.Close()) }()
|
|
require.NoError(t, conn.Raw(func(dc any) error {
|
|
sc, ok := dc.(*sqlite3.SQLiteConn)
|
|
if !ok {
|
|
return fmt.Errorf("reader conn is %T, want *sqlite3.SQLiteConn", dc)
|
|
}
|
|
sc.SetLimit(sqlite3.SQLITE_LIMIT_VARIABLE_NUMBER, limit)
|
|
return nil
|
|
}))
|
|
}
|
|
|
|
// TestGetActivityReport_ManySessionsWithinSQLiteVarLimit reproduces the older
|
|
// SQLite 999-variable limit on the reader pool, then builds a report whose
|
|
// candidate set exceeds it. The usage fetch binds each id chunk twice (the
|
|
// message-where and usage-event-where subqueries) plus two time bounds, so a
|
|
// generic maxSQLVars chunk would emit 2*maxSQLVars+2 = 1002 variables and fail
|
|
// on such builds. The fetch must instead chunk small enough to stay within the
|
|
// limit while still aggregating usage across every chunk.
|
|
func TestGetActivityReport_ManySessionsWithinSQLiteVarLimit(t *testing.T) {
|
|
d := openChunkedAnalyticsFixtureDB(t)
|
|
ctx := context.Background()
|
|
|
|
forceReaderVarLimit(t, d, 999)
|
|
|
|
// Guard: prove the lowered limit is live on the pool, so a setup that
|
|
// failed to constrain it cannot mask the regression checked below.
|
|
overLimitPh, overLimitArgs := inPlaceholders(make([]string, 1001))
|
|
_, probeErr := d.getReader().QueryContext(
|
|
ctx, "SELECT 1 WHERE '' IN "+overLimitPh, overLimitArgs...)
|
|
require.Error(t, probeErr, "reader variable limit was not constrained")
|
|
|
|
r, err := d.GetActivityReport(ctx, AnalyticsFilter{Timezone: "UTC"},
|
|
dayQuery(t, "2024-06-01", "UTC"))
|
|
require.NoError(t, err)
|
|
assert.Len(t, reportSessionIDs(r.BySession),
|
|
chunkedAnalyticsFixtureSessionCount,
|
|
"every candidate session survives id chunking")
|
|
assert.Positive(t, r.Totals.OutputTokens,
|
|
"usage aggregated across all id chunks")
|
|
}
|