//go:build !(windows && arm64) package duckdb import ( "context" "encoding/json" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.kenn.io/agentsview/internal/activity" "go.kenn.io/agentsview/internal/db" ) // duckDayQuery 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 duckDayQuery(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 } // activityReportStore seeds the given writes into a fresh local SQLite DB, // pushes them into DuckDB, and returns a read-only DuckDB store, mirroring // newSyncedStore's sync path. func activityReportStore( t *testing.T, writes []db.SessionBatchWrite, pricing []db.ModelPricing, ) *Store { t.Helper() ctx := context.Background() local := newLocalDB(t) if len(pricing) > 0 { require.NoError(t, local.UpsertModelPricing(pricing)) } _, err := local.WriteSessionBatchAtomic(writes) require.NoError(t, err) syncer := newInMemoryTestSync(t, local, SyncOptions{}) _, err = syncer.Push(ctx, true, nil) require.NoError(t, err) return NewStoreFromDB(syncer.DB()) } func TestDuckGetActivityReportBasicConcurrency(t *testing.T) { ctx := context.Background() // Two overlapping sessions on 2026-06-14 (UTC), each two timestamped // messages, mirroring the SQLite and PostgreSQL parity fixtures. aSession := syncSession("a", "proj1", "alpha first", "2026-06-14T10:00:00.000Z", 2) aSession.Agent = "claude" bSession := syncSession("b", "proj2", "beta first", "2026-06-14T10:01:00.000Z", 2) bSession.Agent = "codex" writes := []db.SessionBatchWrite{ { Session: aSession, Messages: []db.Message{ syncMessage("a", 0, "user", "u", "2026-06-14T10:00:00.000Z"), syncMessage("a", 1, "assistant", "x", "2026-06-14T10:02:00.000Z"), }, DataVersion: 1, ReplaceMessages: true, }, { Session: bSession, Messages: []db.Message{ syncMessage("b", 0, "user", "u", "2026-06-14T10:01:00.000Z"), syncMessage("b", 1, "assistant", "x", "2026-06-14T10:03:00.000Z"), }, DataVersion: 1, ReplaceMessages: true, }, } store := activityReportStore(t, writes, nil) r, err := store.GetActivityReport( ctx, db.AnalyticsFilter{Timezone: "UTC"}, duckDayQuery(t, "2026-06-14", "UTC")) require.NoError(t, err) assert.Equal(t, 2, r.Peak.Agents) assert.Equal(t, 2, r.Totals.Sessions) assert.GreaterOrEqual(t, len(r.ByAgent), 2) } // TestDuckGetActivityReportIncludesSubagentUsage mirrors the SQLite // TestGetActivityReport_IncludesSubagentUsage: subagent and fork sessions // are candidates so their usage lands in the totals (matching daily // usage, which never filters by relationship_type). The fork's replayed // usage row dedups away, so it adds a session row but no cost. func TestDuckGetActivityReportIncludesSubagentUsage(t *testing.T) { ctx := context.Background() root := syncSession("root", "proj1", "root first", "2026-06-14T10:00:00.000Z", 1) rootMsg := syncMessage("root", 0, "assistant", "x", "2026-06-14T10:00:00.000Z") rootMsg.Model = "root-model" rootMsg.TokenUsage = json.RawMessage(`{"input_tokens":1000,"output_tokens":500}`) rootMsg.OutputTokens = 500 rootMsg.ClaudeMessageID = "m-root" rootMsg.ClaudeRequestID = "r-root" parent := "root" sub := syncSession("agent-sub", "proj1", "sub first", "2026-06-14T10:02:00.000Z", 1) sub.RelationshipType = "subagent" sub.ParentSessionID = &parent subMsg := syncMessage("agent-sub", 0, "assistant", "y", "2026-06-14T10:03:00.000Z") subMsg.Model = "sub-model" subMsg.TokenUsage = json.RawMessage(`{"input_tokens":2000,"output_tokens":700}`) subMsg.OutputTokens = 700 subMsg.ClaudeMessageID = "m-sub" subMsg.ClaudeRequestID = "r-sub" fork := syncSession("fork", "proj1", "fork first", "2026-06-14T10:05:00.000Z", 1) fork.RelationshipType = "fork" fork.ParentSessionID = &parent // The fork replays the root's message: same Claude ids, so the dedup // must drop its usage row while the session itself still appears. forkMsg := syncMessage("fork", 0, "assistant", "x", "2026-06-14T10:05:00.000Z") forkMsg.Model = "root-model" forkMsg.TokenUsage = json.RawMessage(`{"input_tokens":1000,"output_tokens":500}`) forkMsg.OutputTokens = 500 forkMsg.ClaudeMessageID = "m-root" forkMsg.ClaudeRequestID = "r-root" writes := []db.SessionBatchWrite{ {Session: root, Messages: []db.Message{rootMsg}, DataVersion: 1, ReplaceMessages: true}, {Session: sub, Messages: []db.Message{subMsg}, DataVersion: 1, ReplaceMessages: true}, {Session: fork, Messages: []db.Message{forkMsg}, DataVersion: 1, ReplaceMessages: true}, } pricing := []db.ModelPricing{ {ModelPattern: "root-model", InputPerMTok: 3.0, OutputPerMTok: 15.0}, {ModelPattern: "sub-model", InputPerMTok: 3.0, OutputPerMTok: 15.0}, } store := activityReportStore(t, writes, pricing) r, err := store.GetActivityReport( ctx, db.AnalyticsFilter{Timezone: "UTC"}, duckDayQuery(t, "2026-06-14", "UTC")) require.NoError(t, err) ids := make(map[string]struct{}, len(r.BySession)) for _, s := range r.BySession { ids[s.SessionID] = struct{}{} } 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) } func TestDuckGetActivityReportUsageCostAndTokens(t *testing.T) { ctx := context.Background() sess := syncSession("s1", "proj1", "first", "2026-06-14T10:30:00.000Z", 1) sess.Agent = "claude" // Override the default token usage to a known input/output split so the // cost is deterministic. msg := syncMessage("s1", 0, "assistant", "x", "2026-06-14T10:30:00.000Z") msg.Model = "claude-sonnet-4-20250514" msg.TokenUsage = json.RawMessage( `{"input_tokens":1000,"output_tokens":500}`) msg.OutputTokens = 500 writes := []db.SessionBatchWrite{{ Session: sess, Messages: []db.Message{msg}, DataVersion: 1, ReplaceMessages: true, }} pricing := []db.ModelPricing{{ ModelPattern: "claude-sonnet-4-20250514", InputPerMTok: 3.0, OutputPerMTok: 15.0, }} store := activityReportStore(t, writes, pricing) r, err := store.GetActivityReport( ctx, db.AnalyticsFilter{Timezone: "UTC"}, duckDayQuery(t, "2026-06-14", "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 TestDuckGetActivityReportPricingModelsOnlyIncludeDedupSurvivors(t *testing.T) { ctx := context.Background() earlier := syncSession("earlier", "proj1", "first", "2026-06-14T10:30:00.000Z", 1) earlier.Agent = "claude" earlierMsg := syncMessage("earlier", 0, "assistant", "x", "2026-06-14T10:30:00.000Z") earlierMsg.Model = "kept-model" earlierMsg.TokenUsage = json.RawMessage( `{"input_tokens":1000,"output_tokens":500}`) earlierMsg.OutputTokens = 500 earlierMsg.ClaudeMessageID = "m-dup" earlierMsg.ClaudeRequestID = "r-dup" later := syncSession("later", "proj1", "first", "2026-06-14T10:31:00.000Z", 1) later.Agent = "claude" laterMsg := syncMessage("later", 0, "assistant", "x", "2026-06-14T10:31:00.000Z") laterMsg.Model = "discarded-model" laterMsg.TokenUsage = json.RawMessage( `{"input_tokens":2000,"output_tokens":900}`) laterMsg.OutputTokens = 900 laterMsg.ClaudeMessageID = "m-dup" laterMsg.ClaudeRequestID = "r-dup" writes := []db.SessionBatchWrite{ { Session: earlier, Messages: []db.Message{earlierMsg}, DataVersion: 1, ReplaceMessages: true, }, { Session: later, Messages: []db.Message{laterMsg}, DataVersion: 1, ReplaceMessages: true, }, } pricing := []db.ModelPricing{ {ModelPattern: "kept-model", InputPerMTok: 3.0, OutputPerMTok: 15.0}, {ModelPattern: "discarded-model", InputPerMTok: 3.0, OutputPerMTok: 15.0}, } store := activityReportStore(t, writes, pricing) r, err := store.GetActivityReport( ctx, db.AnalyticsFilter{Timezone: "UTC"}, duckDayQuery(t, "2026-06-14", "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") } func TestDuckGetActivityReportPreservesSessionSummaryUsageEventTokens(t *testing.T) { ctx := context.Background() rawInput := db.MaxPlausibleTokens + 250_000 rawOutput := db.MaxPlausibleTokens + 500_000 sessionID := "summary-activity" sess := syncSession(sessionID, "proj1", "first", "2026-06-14T10:30:00.000Z", 1) sess.Agent = "hermes" sess.TotalOutputTokens = rawOutput sess.PeakContextTokens = rawInput sess.HasTotalOutputTokens = true sess.HasPeakContextTokens = true msg := syncMessage(sessionID, 0, "user", "first", "2026-06-14T10:30:00.000Z") msg.Model = "" msg.TokenUsage = nil writes := []db.SessionBatchWrite{{ Session: sess, Messages: []db.Message{msg}, UsageEvents: []db.UsageEvent{{ Source: "session", Model: "summary-model", InputTokens: rawInput, OutputTokens: rawOutput, OccurredAt: "2026-06-14T10:30:00.000Z", DedupKey: "summary", }}, DataVersion: 1, ReplaceMessages: true, }} pricing := []db.ModelPricing{{ ModelPattern: "summary-model", InputPerMTok: 1, OutputPerMTok: 2, }} store := activityReportStore(t, writes, pricing) r, err := store.GetActivityReport( ctx, db.AnalyticsFilter{Timezone: "UTC"}, duckDayQuery(t, "2026-06-14", "UTC")) require.NoError(t, err) assert.Equal(t, rawOutput, r.Totals.OutputTokens) wantCost := (float64(rawInput)*1 + float64(rawOutput)*2) / 1_000_000 assert.InDelta(t, wantCost, r.Totals.Cost, 1e-9) } // TestDuckGetActivityReportExcludesIneligibleUsage confirms the DuckDB // usage union (the one backend that inlines its own usage CTE rather // than sharing dailyUsageRowsSQLWithWhere) applies the same eligibility // filters as GetDailyUsage: a synthetic-model message carrying real // token_usage must not inflate the day totals. Mirrors the PostgreSQL // TestPGGetActivityReportExcludesIneligibleUsage. func TestDuckGetActivityReportExcludesIneligibleUsage(t *testing.T) { ctx := context.Background() sess := syncSession("s1", "proj1", "first", "2026-06-14T10:30:00.000Z", 2) sess.Agent = "claude" end := "2026-06-14T10:31:00.000Z" sess.EndedAt = &end eligible := syncMessage("s1", 0, "assistant", "x", "2026-06-14T10:30:00.000Z") eligible.Model = "claude-sonnet-4-20250514" eligible.TokenUsage = json.RawMessage( `{"input_tokens":1000,"output_tokens":500}`) eligible.OutputTokens = 500 // Ineligible: a synthetic-model message carrying real token_usage. The // usage CTE drops m.model == '', so these tokens must NOT leak // into the day totals even though the blob is non-empty. synthetic := syncMessage("s1", 1, "assistant", "y", "2026-06-14T10:31:00.000Z") synthetic.Model = "" synthetic.TokenUsage = json.RawMessage( `{"input_tokens":9000,"output_tokens":7000}`) synthetic.OutputTokens = 7000 writes := []db.SessionBatchWrite{{ Session: sess, Messages: []db.Message{eligible, synthetic}, DataVersion: 1, ReplaceMessages: true, }} pricing := []db.ModelPricing{{ ModelPattern: "claude-sonnet-4-20250514", InputPerMTok: 3.0, OutputPerMTok: 15.0, }} store := activityReportStore(t, writes, pricing) r, err := store.GetActivityReport( ctx, db.AnalyticsFilter{Timezone: "UTC"}, duckDayQuery(t, "2026-06-14", "UTC")) require.NoError(t, err) assert.Equal(t, 500, r.Totals.OutputTokens, "synthetic message excluded") // Cost = (1000*3 + 500*15) / 1e6 = 0.0105 assert.InDelta(t, 0.0105, r.Totals.Cost, 1e-9) } // TestDuckGetActivityReportPriorDayWithinPadExcluded 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 TestDuckGetActivityReportPriorDayWithinPadExcluded(t *testing.T) { ctx := context.Background() today := syncSession("today", "proj1", "today first", "2026-06-14T10:00:00.000Z", 2) today.Agent = "claude" prior := syncSession("prior", "proj2", "prior first", "2026-06-13T12:00:00.000Z", 1) prior.Agent = "codex" priorEnd := "2026-06-13T12:05:00.000Z" prior.EndedAt = &priorEnd writes := []db.SessionBatchWrite{ { Session: today, Messages: []db.Message{ syncMessage("today", 0, "user", "u", "2026-06-14T10:00:00.000Z"), syncMessage("today", 1, "assistant", "x", "2026-06-14T10:02:00.000Z"), }, DataVersion: 1, ReplaceMessages: true, }, { Session: prior, Messages: []db.Message{ syncMessage("prior", 0, "user", "u", "2026-06-13T12:00:00.000Z"), }, DataVersion: 1, ReplaceMessages: true, }, } store := activityReportStore(t, writes, nil) r, err := store.GetActivityReport( ctx, db.AnalyticsFilter{Timezone: "UTC"}, duckDayQuery(t, "2026-06-14", "UTC")) require.NoError(t, err) ids := make(map[string]struct{}, len(r.BySession)) for _, s := range r.BySession { ids[s.SessionID] = struct{}{} } 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) } // TestDuckGetActivityReportOpenSessionWithInRangeMessageIncluded 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, matching SQLite and // PostgreSQL. Mirrors the SQLite // TestGetActivityReport_OpenSessionWithInRangeMessageIncluded. func TestDuckGetActivityReportOpenSessionWithInRangeMessageIncluded(t *testing.T) { ctx := context.Background() // Started the day before and never closed (ended_at nil), active in-range. open := syncSession("open", "proj1", "open first", "2026-06-13T23:00:00.000Z", 2) open.Agent = "claude" open.EndedAt = nil writes := []db.SessionBatchWrite{{ Session: open, Messages: []db.Message{ syncMessage("open", 0, "user", "u", "2026-06-14T10:00:00.000Z"), syncMessage("open", 1, "assistant", "x", "2026-06-14T10:02:00.000Z"), }, DataVersion: 1, ReplaceMessages: true, }} store := activityReportStore(t, writes, nil) r, err := store.GetActivityReport( ctx, db.AnalyticsFilter{Timezone: "UTC"}, duckDayQuery(t, "2026-06-14", "UTC")) require.NoError(t, err) ids := make(map[string]struct{}, len(r.BySession)) for _, s := range r.BySession { ids[s.SessionID] = struct{}{} } 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) } // TestDuckGetActivityReportUsageDedupSubSecondOrder confirms DuckDB orders the // usage stream by the parsed instant so first-seen-wins dedup keeps the // chronologically earlier row when two rows share a dedup key in the same // second -- one whole-second ("...00Z"), one fractional ("...00.123Z"). DuckDB // already sorts on the parsed time (not the formatted text), so this locks in // that cross-backend behavior, matching the SQLite // TestGetActivityReport_UsageDedupSubSecondOrder. func TestDuckGetActivityReportUsageDedupSubSecondOrder(t *testing.T) { ctx := context.Background() pricing := []db.ModelPricing{{ ModelPattern: "claude-sonnet-4-20250514", InputPerMTok: 3.0, OutputPerMTok: 15.0, }} // A resumed/forked pair shares one (claude_message_id, claude_request_id) // across two sessions: the earlier whole-second instant carries 500 output // tokens, the later fractional instant 9000. Dedup must keep the 500 row. earlier := syncSession("earlier", "proj1", "first", "2026-06-14T10:30:00Z", 1) earlierMsg := syncMessage("earlier", 0, "assistant", "x", "2026-06-14T10:30:00Z") earlierMsg.Model = "claude-sonnet-4-20250514" earlierMsg.ClaudeMessageID = "dup-m" earlierMsg.ClaudeRequestID = "dup-r" earlierMsg.TokenUsage = json.RawMessage(`{"input_tokens":1000,"output_tokens":500}`) earlierMsg.OutputTokens = 500 later := syncSession("later", "proj2", "first", "2026-06-14T10:30:00.123Z", 1) laterMsg := syncMessage("later", 0, "assistant", "x", "2026-06-14T10:30:00.123Z") laterMsg.Model = "claude-sonnet-4-20250514" laterMsg.ClaudeMessageID = "dup-m" laterMsg.ClaudeRequestID = "dup-r" laterMsg.TokenUsage = json.RawMessage(`{"input_tokens":1000,"output_tokens":9000}`) laterMsg.OutputTokens = 9000 writes := []db.SessionBatchWrite{ {Session: earlier, Messages: []db.Message{earlierMsg}, DataVersion: 1, ReplaceMessages: true}, {Session: later, Messages: []db.Message{laterMsg}, DataVersion: 1, ReplaceMessages: true}, } store := activityReportStore(t, writes, pricing) r, err := store.GetActivityReport( ctx, db.AnalyticsFilter{Timezone: "UTC"}, duckDayQuery(t, "2026-06-14", "UTC")) require.NoError(t, err) assert.Equal(t, 500, r.Totals.OutputTokens, "first-seen dedup keeps the chronologically earlier whole-second row") } func TestDuckGetActivityReportUsageDedupFallsBackToSourceUUID(t *testing.T) { ctx := context.Background() pricing := []db.ModelPricing{{ ModelPattern: "claude-sonnet-4-20250514", InputPerMTok: 3.0, OutputPerMTok: 15.0, }} earlier := syncSession("earlier", "proj1", "first", "2026-06-14T10:30:00Z", 1) earlier.Agent = "claude" earlierMsg := syncMessage("earlier", 0, "assistant", "x", "2026-06-14T10:30:00Z") earlierMsg.Model = "claude-sonnet-4-20250514" earlierMsg.ClaudeMessageID = "dup-m" earlierMsg.SourceUUID = "src-dup" earlierMsg.TokenUsage = json.RawMessage(`{"input_tokens":1000,"output_tokens":500}`) earlierMsg.OutputTokens = 500 later := syncSession("later", "proj2", "first", "2026-06-14T10:30:01Z", 1) later.Agent = "claude" laterMsg := syncMessage("later", 0, "assistant", "x", "2026-06-14T10:30:01Z") laterMsg.Model = "claude-sonnet-4-20250514" laterMsg.ClaudeMessageID = "dup-m" laterMsg.SourceUUID = "src-dup" laterMsg.TokenUsage = json.RawMessage(`{"input_tokens":1000,"output_tokens":900}`) laterMsg.OutputTokens = 900 writes := []db.SessionBatchWrite{ {Session: earlier, Messages: []db.Message{earlierMsg}, DataVersion: 1, ReplaceMessages: true}, {Session: later, Messages: []db.Message{laterMsg}, DataVersion: 1, ReplaceMessages: true}, } store := activityReportStore(t, writes, pricing) r, err := store.GetActivityReport( ctx, db.AnalyticsFilter{Timezone: "UTC"}, duckDayQuery(t, "2026-06-14", "UTC")) require.NoError(t, err) assert.Equal(t, 500, r.Totals.OutputTokens, "incomplete Claude pairs fall back to source_uuid dedup in activity reports") } // TestDuckGetActivityReportZeroCostKeepsPrimaryModel confirms a usage-only // (untimed) session whose known-model usage carries zero cost still reports // that model as primary through the DuckDB path, guarding the shared zero-cost // fallback end-to-end. Mirrors the aggregator unit test // TestAggregate_UsageOnlySessionZeroCostKeepsPrimaryModel. func TestDuckGetActivityReportZeroCostKeepsPrimaryModel(t *testing.T) { ctx := context.Background() sess := syncSession("u", "proj1", "first", "2026-06-14T10:30:00Z", 1) msg := syncMessage("u", 0, "assistant", "x", "2026-06-14T10:30:00Z") // Known model, unpriced and zero tokens -> a usage row with zero cost. msg.Model = "model-x" msg.TokenUsage = json.RawMessage(`{"input_tokens":0,"output_tokens":0}`) msg.OutputTokens = 0 writes := []db.SessionBatchWrite{{ Session: sess, Messages: []db.Message{msg}, DataVersion: 1, ReplaceMessages: true, }} store := activityReportStore(t, writes, nil) r, err := store.GetActivityReport( ctx, db.AnalyticsFilter{Timezone: "UTC"}, duckDayQuery(t, "2026-06-14", "UTC")) require.NoError(t, err) require.Len(t, r.BySession, 1) assert.Equal(t, "model-x", r.BySession[0].PrimaryModel, "zero-cost usage must still report its known model as primary") } // TestDuckGetActivityReportAutomationFilterAndSessionSplit confirms the shared // AnalyticsFilter automation class is honored through the DuckDB analytics // WHERE builder and that the Totals session-count split survives the sync into // DuckDB. Mirrors the SQLite // TestGetActivityReport_AutomationFilterAndSessionSplit. func TestDuckGetActivityReportAutomationFilterAndSessionSplit(t *testing.T) { ctx := context.Background() // The sync path (WriteSessionBatchAtomic) classifies is_automated from the // transcript: a single-turn session whose first user message matches a // known automated (roborev) prompt prefix. Setting the struct flag alone // would be overridden by updateSessionAutomationFromMessagesTx, so the // automated sessions carry an automated first user message and a single // user turn, exactly as a real roborev review session does. auto1 := syncSession("auto1", "proj1", "You are a code reviewer.", "2026-06-14T10:00:00.000Z", 2) auto1.Agent = "claude" auto2 := syncSession("auto2", "proj1", "You are a code reviewer.", "2026-06-14T11:00:00.000Z", 2) auto2.Agent = "claude" human := syncSession("human", "proj2", "human first", "2026-06-14T12:00:00.000Z", 2) human.Agent = "codex" writes := []db.SessionBatchWrite{ { Session: auto1, Messages: []db.Message{ syncMessage("auto1", 0, "user", "You are a code reviewer.", "2026-06-14T10:00:00.000Z"), syncMessage("auto1", 1, "assistant", "x", "2026-06-14T10:02:00.000Z"), }, DataVersion: 1, ReplaceMessages: true, }, { Session: auto2, Messages: []db.Message{ syncMessage("auto2", 0, "user", "You are a code reviewer.", "2026-06-14T11:00:00.000Z"), syncMessage("auto2", 1, "assistant", "x", "2026-06-14T11:02:00.000Z"), }, DataVersion: 1, ReplaceMessages: true, }, { Session: human, Messages: []db.Message{ syncMessage("human", 0, "user", "u", "2026-06-14T12:00:00.000Z"), syncMessage("human", 1, "assistant", "x", "2026-06-14T12:02:00.000Z"), }, DataVersion: 1, ReplaceMessages: true, }, } store := activityReportStore(t, writes, nil) tests := []struct { name string filter db.AnalyticsFilter wantAutomated int wantInteractive int wantIDs []string }{ { name: "all keeps both classes", filter: db.AnalyticsFilter{Timezone: "UTC"}, wantAutomated: 2, wantInteractive: 1, wantIDs: []string{"auto1", "auto2", "human"}, }, { name: "exclude automated keeps interactive only", filter: db.AnalyticsFilter{Timezone: "UTC", ExcludeAutomated: true}, wantAutomated: 0, wantInteractive: 1, wantIDs: []string{"human"}, }, { name: "exclude interactive keeps automated only", filter: db.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 := store.GetActivityReport(ctx, tc.filter, duckDayQuery(t, "2026-06-14", "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 := make(map[string]struct{}, len(r.BySession)) for _, s := range r.BySession { ids[s.SessionID] = struct{}{} } require.Len(t, ids, len(tc.wantIDs)) for _, id := range tc.wantIDs { assert.Contains(t, ids, id) } }) } }