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

1678 lines
50 KiB
Go

//go:build !(windows && arm64)
package duckdb
import (
"context"
"fmt"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.kenn.io/agentsview/internal/db"
"go.kenn.io/agentsview/internal/export"
)
// TestDuckBuildAnalyticsWhereSubagents verifies that the DuckDB
// analytics WHERE builder mirrors the SQLite rule: when subagents are
// counted, the relationship filter stops excluding them and the
// one-shot exclusion exempts subagent rows (workflow subagents are
// inherently one-shot). The exemption is hand-written DuckDB SQL, so
// this guards its column qualification across prefixed/unprefixed
// callers.
func TestDuckBuildAnalyticsWhereSubagents(t *testing.T) {
t.Run("root-only default", func(t *testing.T) {
f := db.AnalyticsFilter{ExcludeOneShot: true}
where, _ := duckBuildAnalyticsWhere(f, "started_at", "", false, false)
assert.Contains(t, where,
"relationship_type NOT IN ('subagent', 'fork')")
assert.NotContains(t, where, "OR relationship_type = 'subagent'")
})
t.Run("include subagents, unprefixed", func(t *testing.T) {
f := db.AnalyticsFilter{ExcludeOneShot: true, IncludeSubagents: true}
where, _ := duckBuildAnalyticsWhere(f, "started_at", "", false, false)
assert.Contains(t, where, "relationship_type NOT IN ('fork')")
assert.NotContains(t, where, "NOT IN ('subagent'")
// One-shot exclusion exempts subagents.
assert.Contains(t, where, "OR relationship_type = 'subagent')")
})
t.Run("include subagents, prefixed columns stay qualified", func(t *testing.T) {
f := db.AnalyticsFilter{ExcludeOneShot: true, IncludeSubagents: true}
where, _ := duckBuildAnalyticsWhere(f, "s.started_at", "s.", false, false)
assert.Contains(t, where, "s.relationship_type NOT IN ('fork')")
assert.Contains(t, where, "OR s.relationship_type = 'subagent')")
// No unqualified relationship_type leaks through.
assert.False(t,
strings.Contains(where, " relationship_type") ||
strings.HasPrefix(where, "relationship_type"),
"relationship_type must be table-qualified: %s", where)
})
}
func TestDuckAnalyticsAutomatedScopePredicates(t *testing.T) {
tests := []struct {
name string
filter db.AnalyticsFilter
want string
notWant string
}{
{
name: "legacy exclude automated normalizes to human",
filter: db.AnalyticsFilter{
ExcludeAutomated: true,
},
want: "s.is_automated = FALSE",
},
{
name: "all scope suppresses legacy human filter",
filter: db.AnalyticsFilter{
AutomatedScope: "all",
ExcludeAutomated: true,
},
notWant: "s.is_automated = FALSE",
},
{
name: "automated scope selects automated sessions",
filter: db.AnalyticsFilter{
AutomatedScope: "automated",
},
want: "s.is_automated = TRUE",
notWant: "s.is_automated = FALSE",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sql, _ := duckBuildAnalyticsWhere(
tt.filter,
"COALESCE(s.started_at, s.created_at)",
"s.",
false,
false,
)
if tt.want != "" {
assert.Contains(t, sql, tt.want,
"DuckDB analytics SQL missing expected predicate")
}
if tt.notWant != "" {
assert.NotContains(t, sql, tt.notWant,
"DuckDB analytics SQL has unexpected predicate")
}
})
}
}
func TestDuckAnalyticsAutomatedScopeOneShotExemption(t *testing.T) {
sql, _ := duckBuildAnalyticsWhere(
db.AnalyticsFilter{
AutomatedScope: "automated",
ExcludeOneShot: true,
},
"COALESCE(s.started_at, s.created_at)",
"s.",
false,
false,
)
want := "(s.user_message_count > 1 OR s.is_automated = TRUE)"
assert.Contains(t, sql, want,
"DuckDB analytics SQL missing one-shot exemption")
}
func TestDuckAnalyticsModelFilterPredicates(t *testing.T) {
sql, _ := duckBuildAnalyticsWhere(
db.AnalyticsFilter{Model: "gpt-4o"},
"COALESCE(s.started_at, s.created_at)",
"s.",
false,
false,
)
assert.Contains(t, sql,
"EXISTS (SELECT 1 FROM messages m WHERE m.session_id = s.id AND m.model = ?)")
}
func TestDuckAnalyticsModelAndHourUseSameMessagePredicate(t *testing.T) {
hour := 10
sql, _ := duckBuildAnalyticsWhere(
db.AnalyticsFilter{
Model: "gpt-4o",
Hour: &hour,
},
"COALESCE(s.started_at, s.created_at)",
"s.",
false,
true,
)
assert.Contains(t, sql, "m.session_id = s.id")
assert.Contains(t, sql, "m.model = ?")
assert.Contains(t, sql, "CAST(strftime(")
}
func TestDuckUsageTerminationPredicate(t *testing.T) {
where, args := appendDuckUsageSessionFilterClauses(
"WHERE true",
nil,
db.UsageFilter{Termination: "clean,unclean"},
"",
)
assert.Contains(t, where, "s.termination_status = 'clean'")
assert.Contains(t, where, "s.termination_status IN ('tool_call_pending', 'truncated')")
assert.Contains(t, where, "COALESCE(s.ended_at, s.started_at, s.created_at) <= CAST(? AS TIMESTAMP)")
require.Len(t, args, 1)
_, ok := args[0].(string)
assert.True(t, ok, "termination cutoff should be bound as a timestamp string")
}
func TestDuckUsageProjectLabelsPreserveCommas(t *testing.T) {
where, args := appendDuckUsageSessionFilterClauses(
"WHERE true",
nil,
db.UsageFilter{
ProjectLabels: []string{"team,core"},
ExcludeProjectLabels: []string{"other,group"},
},
"",
)
assert.Contains(t, where, "s.project = ?")
assert.Contains(t, where, "s.project != ?")
assert.Equal(t, []any{"team,core", "other,group"}, args)
}
func TestDuckUsageAutomatedScopePredicates(t *testing.T) {
tests := []struct {
name string
filter db.UsageFilter
want string
notWant string
}{
{
name: "legacy exclude automated normalizes to human",
filter: db.UsageFilter{ExcludeAutomated: true},
want: "COALESCE(s.is_automated, FALSE) = FALSE",
},
{
name: "all scope suppresses legacy human filter",
filter: db.UsageFilter{
AutomatedScope: "all",
ExcludeAutomated: true,
},
notWant: "COALESCE(s.is_automated, FALSE) = FALSE",
},
{
name: "automated scope selects automated sessions",
filter: db.UsageFilter{AutomatedScope: "automated"},
want: "COALESCE(s.is_automated, FALSE) = TRUE",
notWant: "COALESCE(s.is_automated, FALSE) = FALSE",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
where, _ := appendDuckUsageSessionFilterClauses(
"WHERE true", nil, tt.filter, "")
if tt.want != "" {
assert.Contains(t, where, tt.want,
"DuckDB usage SQL missing expected predicate")
}
if tt.notWant != "" {
assert.NotContains(t, where, tt.notWant,
"DuckDB usage SQL has unexpected predicate")
}
})
}
}
func TestDuckUsageAggregateCostRecordsMixedReportedAndComputed(t *testing.T) {
resolver := export.NewPricingResolver([]export.EffectivePricingRow{{
ModelPattern: "mixed-model",
Rates: export.ModelRates{
InputPerMTok: 1,
OutputPerMTok: 2,
CacheWritePerMTok: 3,
CacheReadPerMTok: 4,
Source: export.PricingRowSourceFetched,
},
}})
cost, _, priced, contributes := duckUsageAggregateCost(
"mixed-model",
1000, 2000, 3000, 4000,
100, 200, 300, 400, 500,
0.25,
true,
resolver,
)
require.True(t, priced)
require.True(t, contributes)
assert.InDelta(t, 0.25+(100*1+200*2+400*3+500*4)/1_000_000.0, cost, 1e-12)
block, err := resolver.BuildBlock()
require.NoError(t, err)
assert.Equal(t, export.CostSourceMixed, block.CostSource)
assert.Equal(t, export.CostSourceMixed, block.Models["mixed-model"].CostSource)
}
func TestDuckUsageAggregateCostKeepsMixedUnpricedComputedTokensUnpriced(t *testing.T) {
resolver := export.NewPricingResolver(nil)
cost, _, priced, contributes := duckUsageAggregateCost(
"unknown-model",
1000, 2000, 0, 0,
1000, 2000, 0, 0, 0,
0.25,
true,
resolver,
)
require.True(t, contributes)
assert.False(t, priced)
assert.Equal(t, 0.25, cost)
block, err := resolver.BuildBlock()
require.NoError(t, err)
assert.Equal(t, export.CostSourceMixed, block.CostSource)
require.Contains(t, block.Models, "unknown-model")
assert.Equal(t, export.CostSourceMixed, block.Models["unknown-model"].CostSource)
assert.Nil(t, block.Models["unknown-model"].MatchedPattern)
assert.Empty(t, block.Fallback.Models)
}
func TestDuckUsageAggregateCostIncludesReasoningOnlyRows(t *testing.T) {
resolver := export.NewPricingResolver([]export.EffectivePricingRow{{
ModelPattern: "reasoning-model",
Rates: export.ModelRates{
OutputPerMTok: 2,
Source: export.PricingRowSourceFetched,
},
}})
cost, _, priced, contributes := duckUsageAggregateCost(
"reasoning-model",
0, 0, 0, 0,
0, 0, 300, 0, 0,
0,
false,
resolver,
)
require.True(t, contributes)
assert.True(t, priced)
assert.InDelta(t, 0.0006, cost, 1e-12)
block, err := resolver.BuildBlock()
require.NoError(t, err)
require.Contains(t, block.Models, "reasoning-model")
assert.Equal(t, export.CostSourceComputed,
block.Models["reasoning-model"].CostSource)
}
func TestDuckUsageAggregateCostRecordsZeroTokenModelProvenance(t *testing.T) {
resolver := export.NewPricingResolver([]export.EffectivePricingRow{{
ModelPattern: "zero-model",
Rates: export.ModelRates{
InputPerMTok: 1,
Source: export.PricingRowSourceFetched,
},
}})
cost, _, priced, contributes := duckUsageAggregateCost(
"zero-model",
0, 0, 0, 0,
0, 0, 0, 0, 0,
0,
false,
resolver,
)
assert.True(t, priced)
assert.False(t, contributes)
assert.Zero(t, cost)
block, err := resolver.BuildBlock()
require.NoError(t, err)
require.Contains(t, block.Models, "zero-model")
assert.Equal(t, export.CostSourceComputed,
block.Models["zero-model"].CostSource)
}
func TestDuckUsageAutomatedScopeOneShotExemption(t *testing.T) {
where, _ := appendDuckUsageSessionFilterClauses(
"WHERE true",
nil,
db.UsageFilter{AutomatedScope: "automated", ExcludeOneShot: true},
"",
)
assert.Contains(t, where,
"(s.user_message_count > 1 OR COALESCE(s.is_automated, FALSE) = TRUE)",
"DuckDB usage SQL missing non-human one-shot exemption")
humanWhere, _ := appendDuckUsageSessionFilterClauses(
"WHERE true",
nil,
db.UsageFilter{AutomatedScope: "human", ExcludeOneShot: true},
"",
)
assert.Contains(t, humanWhere, "AND s.user_message_count > 1",
"DuckDB usage human one-shot must be a plain threshold")
assert.NotContains(t, humanWhere,
"OR COALESCE(s.is_automated, FALSE) = TRUE",
"DuckDB usage human one-shot must not exempt automated sessions")
}
func TestDuckSignalMessagesFormatsTimestampValues(t *testing.T) {
ctx := context.Background()
store, _ := newSyncedStore(t)
_, err := store.duck.ExecContext(ctx, `
INSERT INTO messages (
id, session_id, ordinal, role, content, timestamp,
is_system, has_tool_use
) VALUES
(9101, 'signal-time', 0, 'user', 'with timestamp',
CAST('2026-01-20T12:34:56Z' AS TIMESTAMP), FALSE, FALSE),
(9102, 'signal-time', 1, 'assistant', 'without timestamp',
NULL, FALSE, FALSE)`)
require.NoError(t, err)
got, err := store.duckSignalMessages(
ctx,
[]db.SignalRow{{ID: "signal-time"}},
db.AnalyticsFilter{},
)
require.NoError(t, err)
require.Len(t, got["signal-time"], 2)
assert.Equal(t, "2026-01-20T12:34:56Z", got["signal-time"][0].Timestamp)
assert.Empty(t, got["signal-time"][1].Timestamp)
}
func TestDuckAnalyticsSignalSessionsModelFilterUsesMatchingMessages(
t *testing.T,
) {
ctx := context.Background()
store := newDuckAnalyticsStore(t, []db.SessionBatchWrite{
{
Session: syncSession(
"duck-signal-mixed", "alpha", "mixed",
"2024-06-01T09:00:00Z", 2,
),
Messages: []db.Message{
duckModelMessage(
"duck-signal-mixed", 0, "assistant",
"claude tool evidence",
"2024-06-01T09:05:00Z",
"claude-3-5-sonnet",
db.ToolCall{ToolName: "Grep", Category: "Grep"},
),
duckModelMessage(
"duck-signal-mixed", 1, "assistant",
"gpt tool evidence",
"2024-06-01T09:06:00Z",
"gpt-4o",
db.ToolCall{ToolName: "Read", Category: "Read"},
),
},
Signals: db.SessionSignalUpdate{
ToolFailureSignalCount: 1,
},
DataVersion: 1,
ReplaceMessages: true,
},
})
resp, err := store.GetAnalyticsSignalSessions(ctx, db.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 TestDuckAnalyticsSignalSessionsModelFilterKeepsParserUserEvidence(
t *testing.T,
) {
ctx := context.Background()
store := newDuckAnalyticsStore(t, []db.SessionBatchWrite{
{
Session: syncSession(
"duck-signal-parser-user", "alpha", "mixed",
"2024-06-01T09:00:00Z", 2,
),
Messages: []db.Message{
duckModelMessage(
"duck-signal-parser-user", 0, "user",
"help", "2024-06-01T09:00:00Z", "",
),
duckModelMessage(
"duck-signal-parser-user", 1, "assistant",
"reply", "2024-06-01T09:01:00Z", "gpt-4o",
db.ToolCall{ToolName: "Read", Category: "Read"},
),
},
Signals: db.SessionSignalUpdate{
QualitySignals: db.QualitySignals{
Version: 1,
ShortPromptCount: 1,
},
},
DataVersion: 1,
ReplaceMessages: true,
},
})
resp, err := store.GetAnalyticsSignalSessions(ctx, db.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 TestDuckAnalyticsSummaryModelFilterPopulatesModels(t *testing.T) {
ctx := context.Background()
start := "2024-06-01T09:00:00Z"
store := newDuckAnalyticsStore(t, []db.SessionBatchWrite{
{
Session: syncSession("duck-model-a", "alpha", "gpt", start, 1),
Messages: []db.Message{
duckModelMessage("duck-model-a", 0, "assistant", "gpt", start, "gpt-4o"),
},
DataVersion: 1,
ReplaceMessages: true,
},
{
Session: syncSession("duck-model-b", "alpha", "claude", start, 1),
Messages: []db.Message{
duckModelMessage("duck-model-b", 0, "assistant", "claude", start, "claude-3-5-sonnet"),
},
DataVersion: 1,
ReplaceMessages: true,
},
})
resp, err := store.GetAnalyticsSummary(ctx, db.AnalyticsFilter{
From: "2024-06-01", To: "2024-06-01", Timezone: "UTC",
Model: "gpt-4o",
})
require.NoError(t, err, "GetAnalyticsSummary")
assert.Equal(t, 1, resp.TotalSessions, "TotalSessions")
assert.Equal(t, []string{"gpt-4o"}, resp.Models, "Models")
}
func TestDuckAnalyticsMixedModelFilters(t *testing.T) {
ctx := context.Background()
store := newDuckMixedModelAnalyticsStore(t)
t.Run("summary counts only matching messages", func(t *testing.T) {
assertDuckAnalyticsSummaryModelFilterCountsOnlyMatchingMessages(t, ctx, store)
})
t.Run("activity counts only matching messages", func(t *testing.T) {
assertDuckAnalyticsActivityModelFilterCountsOnlyMatchingMessages(t, ctx, store)
})
t.Run("hour of week counts only matching messages", func(t *testing.T) {
assertDuckAnalyticsHourOfWeekModelFilterCountsOnlyMatchingMessages(t, ctx, store)
})
t.Run("tools count only matching calls", func(t *testing.T) {
assertDuckAnalyticsToolsModelFilterCountsOnlyMatchingToolCalls(t, ctx, store)
})
t.Run("skills count only matching calls", func(t *testing.T) {
assertDuckAnalyticsSkillsModelFilterCountsOnlyMatchingSkillCalls(t, ctx, store)
})
t.Run("projects count only matching messages", func(t *testing.T) {
assertDuckAnalyticsProjectsModelFilterCountsOnlyMatchingMessages(t, ctx, store)
})
t.Run("heatmap counts only matching messages", func(t *testing.T) {
assertDuckAnalyticsHeatmapModelFilterCountsOnlyMatchingMessages(t, ctx, store)
})
}
func assertDuckAnalyticsSummaryModelFilterCountsOnlyMatchingMessages(
t *testing.T,
ctx context.Context,
store *Store,
) {
t.Helper()
resp, err := store.GetAnalyticsSummary(ctx, db.AnalyticsFilter{
From: "2024-06-01", To: "2024-06-01", Timezone: "UTC",
Model: "gpt-4o",
})
require.NoError(t, err, "GetAnalyticsSummary")
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.Len(t, resp.Agents, 1, "len(Agents)")
for _, summary := range resp.Agents {
assert.Equal(t, 1, summary.Messages, "AgentMessages")
}
}
func TestDuckAnalyticsSummaryModelsUseMatchingHourRowsOnly(t *testing.T) {
ctx := context.Background()
store := newDuckAnalyticsStore(t, []db.SessionBatchWrite{
{
Session: syncSession(
"duck-summary-hour-mixed", "alpha", "mixed",
"2024-06-01T09:00:00Z", 2,
),
Messages: []db.Message{
duckModelMessage(
"duck-summary-hour-mixed", 0, "assistant", "gpt",
"2024-06-01T09:05:00Z", "gpt-4o",
),
duckModelMessage(
"duck-summary-hour-mixed", 1, "assistant", "claude",
"2024-06-01T10:05:00Z", "claude-3-5-sonnet",
),
},
DataVersion: 1,
ReplaceMessages: true,
},
})
hour := 9
resp, err := store.GetAnalyticsSummary(ctx, db.AnalyticsFilter{
From: "2024-06-01", To: "2024-06-01", Timezone: "UTC",
Hour: &hour,
})
require.NoError(t, err, "GetAnalyticsSummary")
assert.Equal(t, 1, resp.TotalSessions, "TotalSessions")
assert.Equal(t, []string{"gpt-4o"}, resp.Models, "Models")
}
func TestDuckAnalyticsSummaryModelFilterUsesFilteredOutputTokens(
t *testing.T,
) {
ctx := context.Background()
mixedSession := syncSession(
"duck-summary-output-mixed", "alpha", "mixed",
"2024-06-01T10:00:00Z", 2,
)
mixedSession.TotalOutputTokens = 111
mixedSession.HasTotalOutputTokens = true
mixedGpt := duckModelMessage(
"duck-summary-output-mixed", 0, "assistant", "gpt",
"2024-06-01T10:00:00Z", "gpt-4o",
)
mixedGpt.TokenUsage = []byte(`{"output_tokens":11}`)
mixedGpt.OutputTokens = 11
mixedGpt.HasOutputTokens = true
mixedClaude := duckModelMessage(
"duck-summary-output-mixed", 1, "assistant", "claude",
"2024-06-01T10:05:00Z", "claude-3-5-sonnet",
)
mixedClaude.TokenUsage = []byte(`{"output_tokens":100}`)
mixedClaude.OutputTokens = 100
mixedClaude.HasOutputTokens = true
uncoveredSession := syncSession(
"duck-summary-output-uncovered", "alpha", "mixed",
"2024-06-01T10:40:00Z", 2,
)
uncoveredSession.TotalOutputTokens = 90
uncoveredSession.HasTotalOutputTokens = true
uncoveredGpt := duckModelMessage(
"duck-summary-output-uncovered", 0, "assistant", "gpt",
"2024-06-01T10:40:00Z", "gpt-4o",
)
uncoveredGpt.TokenUsage = nil
uncoveredGpt.ContextTokens = 0
uncoveredGpt.OutputTokens = 0
uncoveredGpt.HasContextTokens = false
uncoveredGpt.HasOutputTokens = false
uncoveredClaude := duckModelMessage(
"duck-summary-output-uncovered", 1, "assistant", "claude",
"2024-06-01T10:45:00Z", "claude-3-5-sonnet",
)
uncoveredClaude.TokenUsage = []byte(`{"output_tokens":90}`)
uncoveredClaude.OutputTokens = 90
uncoveredClaude.HasOutputTokens = true
store := newDuckAnalyticsStore(t, []db.SessionBatchWrite{
{
Session: mixedSession,
Messages: []db.Message{
mixedGpt,
mixedClaude,
},
DataVersion: 1,
ReplaceMessages: true,
},
{
Session: uncoveredSession,
Messages: []db.Message{
uncoveredGpt,
uncoveredClaude,
},
DataVersion: 1,
ReplaceMessages: true,
},
})
hour := 10
resp, err := store.GetAnalyticsSummary(ctx, db.AnalyticsFilter{
From: "2024-06-01", To: "2024-06-01", Timezone: "UTC",
Model: "gpt-4o", Hour: &hour,
})
require.NoError(t, err, "GetAnalyticsSummary")
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 assertDuckAnalyticsActivityModelFilterCountsOnlyMatchingMessages(
t *testing.T,
ctx context.Context,
store *Store,
) {
t.Helper()
resp, err := store.GetAnalyticsActivity(ctx, db.AnalyticsFilter{
From: "2024-06-01", To: "2024-06-01", Timezone: "UTC",
Model: "gpt-4o",
}, "day")
require.NoError(t, err, "GetAnalyticsActivity")
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, 2, resp.Series[0].ToolCalls, "ToolCalls")
}
func TestDuckAnalyticsActivityModelAndHourFilterCountsOnlyMatchingHourRows(
t *testing.T,
) {
ctx := context.Background()
readMsg := duckModelMessage(
"duck-activity-hour-gpt", 0, "assistant", "read",
"2024-06-01T09:00:00Z", "gpt-4o",
db.ToolCall{
ToolName: "Read", Category: "Read",
},
db.ToolCall{
ToolName: "Bash", Category: "Bash",
},
)
grepMsg := duckModelMessage(
"duck-activity-hour-gpt", 1, "assistant", "grep",
"2024-06-01T10:00:00Z", "gpt-4o",
db.ToolCall{
ToolName: "Grep", Category: "Grep",
},
)
store := newDuckAnalyticsStore(t, []db.SessionBatchWrite{
{
Session: syncSession(
"duck-activity-hour-gpt", "alpha", "mixed",
"2024-06-01T09:00:00Z", 2,
),
Messages: []db.Message{
readMsg,
grepMsg,
},
DataVersion: 1,
ReplaceMessages: true,
},
})
hour := 10
resp, err := store.GetAnalyticsActivity(ctx, db.AnalyticsFilter{
From: "2024-06-01", To: "2024-06-01", Timezone: "UTC",
Model: "gpt-4o", Hour: &hour,
}, "day")
require.NoError(t, err, "GetAnalyticsActivity")
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 assertDuckAnalyticsHourOfWeekModelFilterCountsOnlyMatchingMessages(
t *testing.T,
ctx context.Context,
store *Store,
) {
t.Helper()
resp, err := store.GetAnalyticsHourOfWeek(ctx, db.AnalyticsFilter{
From: "2024-06-01", To: "2024-06-01", Timezone: "UTC",
Model: "gpt-4o",
})
require.NoError(t, err, "GetAnalyticsHourOfWeek")
assert.Equal(t, 1, duckHOWMessages(resp.Cells, 5, 9), "Sat 09:00")
assert.Equal(t, 0, duckHOWMessages(resp.Cells, 5, 10), "Sat 10:00")
}
func TestDuckAnalyticsHourOfWeekModelFilterIncludesPairedUserTurns(
t *testing.T,
) {
ctx := context.Background()
start := "2024-06-01T09:00:00Z"
reply := "2024-06-01T10:00:00Z"
userMsg := syncMessage("duck-paired-model", 0, "user", "q", start)
userMsg.Model = ""
assistantMsg := duckModelMessage(
"duck-paired-model", 1, "assistant", "a", reply, "gpt-4o",
)
store := newDuckAnalyticsStore(t, []db.SessionBatchWrite{
{
Session: syncSession(
"duck-paired-model", "alpha", "q", start, 2,
),
Messages: []db.Message{userMsg, assistantMsg},
DataVersion: 1,
ReplaceMessages: true,
},
})
resp, err := store.GetAnalyticsHourOfWeek(ctx, db.AnalyticsFilter{
From: "2024-06-01", To: "2024-06-01", Timezone: "UTC",
Model: "gpt-4o",
})
require.NoError(t, err, "GetAnalyticsHourOfWeek")
assert.Equal(t, 1, duckHOWMessages(resp.Cells, 5, 9),
"paired empty-model user turn at Sat 09:00")
assert.Equal(t, 1, duckHOWMessages(resp.Cells, 5, 10),
"selected-model assistant at Sat 10:00")
}
func TestDuckAnalyticsActivityModelAndHourFilterKeepsPairedUserTurn(
t *testing.T,
) {
ctx := context.Background()
userMsg := syncMessage(
"duck-activity-paired-hour", 0, "user", "q", "2024-06-01T09:00:00Z",
)
userMsg.Model = ""
assistantMsg := duckModelMessage(
"duck-activity-paired-hour", 1, "assistant", "a",
"2024-06-01T10:00:00Z", "gpt-4o",
)
store := newDuckAnalyticsStore(t, []db.SessionBatchWrite{
{
Session: syncSession(
"duck-activity-paired-hour", "alpha", "q",
"2024-06-01T09:00:00Z", 2,
),
Messages: []db.Message{userMsg, assistantMsg},
DataVersion: 1,
ReplaceMessages: true,
},
})
// Paired empty-model user turn at hour 9; gpt-4o assistant at hour 10.
// Filtering by hour 9 must keep the session via the paired user turn.
hour := 9
resp, err := store.GetAnalyticsActivity(ctx, db.AnalyticsFilter{
From: "2024-06-01", To: "2024-06-01", Timezone: "UTC",
Model: "gpt-4o", Hour: &hour,
}, "day")
require.NoError(t, err, "GetAnalyticsActivity")
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 TestDuckAnalyticsHeatmapSessionsModelAndHourFilterKeepsPairedUserTurn(
t *testing.T,
) {
ctx := context.Background()
userMsg := syncMessage(
"duck-heatmap-sessions-paired", 0, "user", "q",
"2024-06-01T09:00:00Z",
)
userMsg.Model = ""
assistantMsg := duckModelMessage(
"duck-heatmap-sessions-paired", 1, "assistant", "a",
"2024-06-01T10:00:00Z", "gpt-4o",
)
store := newDuckAnalyticsStore(t, []db.SessionBatchWrite{
{
Session: syncSession(
"duck-heatmap-sessions-paired", "alpha", "q",
"2024-06-01T09:00:00Z", 2,
),
Messages: []db.Message{userMsg, assistantMsg},
DataVersion: 1,
ReplaceMessages: true,
},
})
// 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
// instead of requiring a gpt-4o message at hour 9.
hour := 9
resp, err := store.GetAnalyticsHeatmap(ctx, db.AnalyticsFilter{
From: "2024-06-01", To: "2024-06-01", Timezone: "UTC",
Model: "gpt-4o", Hour: &hour,
}, "sessions")
require.NoError(t, err, "GetAnalyticsHeatmap")
require.Len(t, resp.Entries, 1, "len(Entries)")
assert.Equal(t, 1, resp.Entries[0].Value, "Value")
}
func TestDuckAnalyticsTopSessionsDurationModelAndHourFilterKeepsPairedUserTurn(
t *testing.T,
) {
ctx := context.Background()
session := syncSession(
"duck-top-duration-paired", "alpha", "q",
"2024-06-01T09:00:00Z", 2,
)
endedAt := "2024-06-01T10:00:00Z"
session.EndedAt = &endedAt
userMsg := syncMessage(
"duck-top-duration-paired", 0, "user", "q",
"2024-06-01T09:00:00Z",
)
userMsg.Model = ""
assistantMsg := duckModelMessage(
"duck-top-duration-paired", 1, "assistant", "a",
"2024-06-01T10:00:00Z", "gpt-4o",
)
store := newDuckAnalyticsStore(t, []db.SessionBatchWrite{
{
Session: session,
Messages: []db.Message{userMsg, assistantMsg},
DataVersion: 1,
ReplaceMessages: true,
},
})
// Empty-model user turn at hour 9 paired with a gpt-4o assistant at hour
// 10. Ranking sessions by duration under the gpt-4o + hour-9 filter must
// keep the session via the paired user turn.
hour := 9
resp, err := store.GetAnalyticsTopSessions(ctx, db.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, "duck-top-duration-paired", resp.Sessions[0].ID, "ID")
}
func TestDuckAnalyticsTopSessionsDurationModelFilterRanksAndLimitsScopedSet(
t *testing.T,
) {
ctx := context.Background()
// 12 gpt-4o sessions in hour 9 with distinct active durations (message gap
// k*20s, under the 5-minute cap). The model+hour duration path filters the
// scoped set and limits in Go without binding every ID into one IN list, so
// it must return the 10 longest by active duration, not all 12.
var writes []db.SessionBatchWrite
for k := 1; k <= 12; k++ {
id := fmt.Sprintf("duck-top-dur-rank-%02d", k)
start := "2024-06-01T09:00:00Z"
gap := k * 20
end := fmt.Sprintf("2024-06-01T09:%02d:%02dZ", gap/60, gap%60)
session := syncSession(id, "alpha", "q", start, 2)
session.EndedAt = &end
userMsg := syncMessage(id, 0, "user", "q", start)
userMsg.Model = ""
writes = append(writes, db.SessionBatchWrite{
Session: session,
Messages: []db.Message{
userMsg,
duckModelMessage(id, 1, "assistant", "a", end, "gpt-4o"),
},
DataVersion: 1,
ReplaceMessages: true,
})
}
store := newDuckAnalyticsStore(t, writes)
hour := 9
resp, err := store.GetAnalyticsTopSessions(ctx, db.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, 10, "top sessions capped at 10")
// Longest active duration first: k=12 (240s) down to k=3 (60s).
assert.Equal(t, "duck-top-dur-rank-12", resp.Sessions[0].ID, "longest")
assert.Equal(t, "duck-top-dur-rank-03", resp.Sessions[9].ID, "tenth")
ids := map[string]bool{}
for _, session := range resp.Sessions {
ids[session.ID] = true
}
assert.False(t, ids["duck-top-dur-rank-01"], "shortest excluded")
assert.False(t, ids["duck-top-dur-rank-02"], "second shortest excluded")
}
func assertDuckAnalyticsToolsModelFilterCountsOnlyMatchingToolCalls(
t *testing.T,
ctx context.Context,
store *Store,
) {
t.Helper()
resp, err := store.GetAnalyticsTools(ctx, db.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")
byCategory := map[string]int{}
for _, row := range resp.ByCategory {
byCategory[row.Category] = row.Count
}
assert.Equal(t, 1, byCategory["Read"], "Read")
assert.Equal(t, 1, byCategory["Skill"], "Skill")
assert.Zero(t, byCategory["Grep"], "Grep")
byTool := map[string]int{}
for _, row := range resp.ByTool {
byTool[row.ToolName] = row.CallCount
}
assert.Equal(t, 1, byTool["Read"], "Read tool")
assert.Equal(t, 1, byTool["Skill"], "Skill tool")
assert.Zero(t, byTool["Grep"], "Grep tool")
}
func TestDuckAnalyticsToolsModelAndHourFilterCountsOnlyMatchingHourToolCalls(
t *testing.T,
) {
ctx := context.Background()
store := newDuckAnalyticsStore(t, []db.SessionBatchWrite{
{
Session: syncSession(
"duck-tool-model-hour", "alpha", "mixed",
"2024-06-01T09:00:00Z", 2,
),
Messages: []db.Message{
duckModelMessage(
"duck-tool-model-hour", 0, "assistant",
"read", "2024-06-01T09:00:00Z", "gpt-4o",
db.ToolCall{ToolName: "Read", Category: "Read"},
db.ToolCall{ToolName: "Bash", Category: "Bash"},
),
duckModelMessage(
"duck-tool-model-hour", 1, "assistant",
"grep", "2024-06-01T10:00:00Z", "gpt-4o",
db.ToolCall{ToolName: "Grep", Category: "Grep"},
),
},
DataVersion: 1,
ReplaceMessages: true,
},
})
hour := 10
resp, err := store.GetAnalyticsTools(ctx, db.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")
require.Len(t, resp.ByTool, 1, "len(ByTool)")
assert.Equal(t, "Grep", resp.ByTool[0].ToolName, "ToolName")
assert.Equal(t, 1, resp.ByTool[0].CallCount, "CallCount")
assert.Equal(t, 1, resp.ByTool[0].SessionCount, "SessionCount")
}
func assertDuckAnalyticsSkillsModelFilterCountsOnlyMatchingSkillCalls(
t *testing.T,
ctx context.Context,
store *Store,
) {
t.Helper()
resp, err := store.GetAnalyticsSkills(ctx, db.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")
}
func assertDuckAnalyticsProjectsModelFilterCountsOnlyMatchingMessages(
t *testing.T,
ctx context.Context,
store *Store,
) {
t.Helper()
resp, err := store.GetAnalyticsProjects(ctx, db.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 assertDuckAnalyticsHeatmapModelFilterCountsOnlyMatchingMessages(
t *testing.T,
ctx context.Context,
store *Store,
) {
t.Helper()
resp, err := store.GetAnalyticsHeatmap(ctx, db.AnalyticsFilter{
From: "2024-06-01", To: "2024-06-01", Timezone: "UTC",
Model: "gpt-4o",
}, "messages")
require.NoError(t, err, "GetAnalyticsHeatmap")
require.Len(t, resp.Entries, 1, "len(Entries)")
assert.Equal(t, 1, resp.Entries[0].Value, "Value")
}
func TestDuckAnalyticsHeatmapModelFilterUsesFilteredOutputTokens(
t *testing.T,
) {
ctx := context.Background()
mixedSession := syncSession(
"duck-heatmap-output-mixed", "alpha", "mixed",
"2024-06-01T10:00:00Z", 2,
)
mixedSession.TotalOutputTokens = 111
mixedSession.HasTotalOutputTokens = true
mixedGpt := duckModelMessage(
"duck-heatmap-output-mixed", 0, "assistant", "gpt",
"2024-06-01T10:00:00Z", "gpt-4o",
)
mixedGpt.TokenUsage = []byte(`{"output_tokens":11}`)
mixedGpt.OutputTokens = 11
mixedGpt.HasOutputTokens = true
mixedClaude := duckModelMessage(
"duck-heatmap-output-mixed", 1, "assistant", "claude",
"2024-06-01T10:05:00Z", "claude-3-5-sonnet",
)
mixedClaude.TokenUsage = []byte(`{"output_tokens":100}`)
mixedClaude.OutputTokens = 100
mixedClaude.HasOutputTokens = true
uncoveredSession := syncSession(
"duck-heatmap-output-uncovered", "alpha", "mixed",
"2024-06-01T10:40:00Z", 2,
)
uncoveredSession.TotalOutputTokens = 90
uncoveredSession.HasTotalOutputTokens = true
uncoveredGpt := duckModelMessage(
"duck-heatmap-output-uncovered", 0, "assistant", "gpt",
"2024-06-01T10:40:00Z", "gpt-4o",
)
uncoveredGpt.TokenUsage = nil
uncoveredGpt.ContextTokens = 0
uncoveredGpt.OutputTokens = 0
uncoveredGpt.HasContextTokens = false
uncoveredGpt.HasOutputTokens = false
uncoveredClaude := duckModelMessage(
"duck-heatmap-output-uncovered", 1, "assistant", "claude",
"2024-06-01T10:45:00Z", "claude-3-5-sonnet",
)
uncoveredClaude.TokenUsage = []byte(`{"output_tokens":90}`)
uncoveredClaude.OutputTokens = 90
uncoveredClaude.HasOutputTokens = true
store := newDuckAnalyticsStore(t, []db.SessionBatchWrite{
{
Session: mixedSession,
Messages: []db.Message{
mixedGpt,
mixedClaude,
},
DataVersion: 1,
ReplaceMessages: true,
},
{
Session: uncoveredSession,
Messages: []db.Message{
uncoveredGpt,
uncoveredClaude,
},
DataVersion: 1,
ReplaceMessages: true,
},
})
hour := 10
resp, err := store.GetAnalyticsHeatmap(ctx, db.AnalyticsFilter{
From: "2024-06-01", To: "2024-06-01", Timezone: "UTC",
Model: "gpt-4o", Hour: &hour,
}, "output_tokens")
require.NoError(t, err, "GetAnalyticsHeatmap")
require.Len(t, resp.Entries, 1, "len(Entries)")
assert.Equal(t, 11, resp.Entries[0].Value, "Value")
}
func TestDuckAnalyticsTopSessionsMessagesUseFilteredModelCounts(
t *testing.T,
) {
ctx := context.Background()
store := newDuckAnalyticsStore(t, []db.SessionBatchWrite{
{
Session: syncSession(
"duck-top-mixed", "alpha", "mixed",
"2024-06-01T09:00:00Z", 3,
),
Messages: []db.Message{
duckModelMessage(
"duck-top-mixed", 0, "assistant", "gpt",
"2024-06-01T09:00:00Z", "gpt-4o",
),
duckModelMessage(
"duck-top-mixed", 1, "assistant", "claude",
"2024-06-01T09:05:00Z", "claude-3-5-sonnet",
),
duckModelMessage(
"duck-top-mixed", 2, "assistant", "claude",
"2024-06-01T09:06:00Z", "claude-3-5-sonnet",
),
},
DataVersion: 1,
ReplaceMessages: true,
},
{
Session: syncSession(
"duck-top-gpt", "alpha", "gpt",
"2024-06-01T11:00:00Z", 2,
),
Messages: []db.Message{
duckModelMessage(
"duck-top-gpt", 0, "assistant", "gpt",
"2024-06-01T11:00:00Z", "gpt-4o",
),
duckModelMessage(
"duck-top-gpt", 1, "assistant", "gpt",
"2024-06-01T11:05:00Z", "gpt-4o",
),
},
DataVersion: 1,
ReplaceMessages: true,
},
})
resp, err := store.GetAnalyticsTopSessions(ctx, db.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, "duck-top-gpt", resp.Sessions[0].ID, "top session")
assert.Equal(t, 2, resp.Sessions[0].MessageCount, "top MessageCount")
assert.Equal(t, "duck-top-mixed", resp.Sessions[1].ID, "second session")
assert.Equal(t, 1, resp.Sessions[1].MessageCount, "second MessageCount")
}
func TestDuckAnalyticsTopSessionsOutputTokensUseFilteredModelTotals(
t *testing.T,
) {
ctx := context.Background()
mixedSession := syncSession(
"duck-top-output-mixed", "alpha", "mixed",
"2024-06-01T09:00:00Z", 2,
)
mixedSession.TotalOutputTokens = 510
mixedSession.HasTotalOutputTokens = true
mixedGpt := duckModelMessage(
"duck-top-output-mixed", 0, "assistant", "gpt",
"2024-06-01T09:00:00Z", "gpt-4o",
)
mixedGpt.TokenUsage = []byte(`{"output_tokens":10}`)
mixedGpt.OutputTokens = 10
mixedGpt.HasOutputTokens = true
mixedClaude := duckModelMessage(
"duck-top-output-mixed", 1, "assistant", "claude",
"2024-06-01T09:05:00Z", "claude-3-5-sonnet",
)
mixedClaude.TokenUsage = []byte(`{"output_tokens":500}`)
mixedClaude.OutputTokens = 500
mixedClaude.HasOutputTokens = true
gptSession := syncSession(
"duck-top-output-gpt", "alpha", "gpt",
"2024-06-01T11:00:00Z", 1,
)
gptSession.TotalOutputTokens = 30
gptSession.HasTotalOutputTokens = true
gptMsg := duckModelMessage(
"duck-top-output-gpt", 0, "assistant", "gpt",
"2024-06-01T11:00:00Z", "gpt-4o",
)
gptMsg.TokenUsage = []byte(`{"output_tokens":30}`)
gptMsg.OutputTokens = 30
gptMsg.HasOutputTokens = true
uncoveredSession := syncSession(
"duck-top-output-uncovered", "alpha", "mixed",
"2024-06-01T13:00:00Z", 2,
)
uncoveredSession.TotalOutputTokens = 900
uncoveredSession.HasTotalOutputTokens = true
uncoveredGpt := duckModelMessage(
"duck-top-output-uncovered", 0, "assistant", "gpt",
"2024-06-01T13:00:00Z", "gpt-4o",
)
uncoveredGpt.TokenUsage = nil
uncoveredGpt.ContextTokens = 0
uncoveredGpt.OutputTokens = 0
uncoveredGpt.HasContextTokens = false
uncoveredGpt.HasOutputTokens = false
uncoveredClaude := duckModelMessage(
"duck-top-output-uncovered", 1, "assistant", "claude",
"2024-06-01T13:05:00Z", "claude-3-5-sonnet",
)
uncoveredClaude.TokenUsage = []byte(`{"output_tokens":900}`)
uncoveredClaude.OutputTokens = 900
uncoveredClaude.HasOutputTokens = true
store := newDuckAnalyticsStore(t, []db.SessionBatchWrite{
{
Session: mixedSession,
Messages: []db.Message{
mixedGpt,
mixedClaude,
},
DataVersion: 1,
ReplaceMessages: true,
},
{
Session: gptSession,
Messages: []db.Message{
gptMsg,
},
DataVersion: 1,
ReplaceMessages: true,
},
{
Session: uncoveredSession,
Messages: []db.Message{
uncoveredGpt,
uncoveredClaude,
},
DataVersion: 1,
ReplaceMessages: true,
},
})
resp, err := store.GetAnalyticsTopSessions(ctx, db.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, "duck-top-output-gpt", resp.Sessions[0].ID,
"top session")
assert.Equal(t, 30, resp.Sessions[0].OutputTokens,
"top OutputTokens")
assert.Equal(t, "duck-top-output-mixed", resp.Sessions[1].ID,
"second session")
assert.Equal(t, 10, resp.Sessions[1].OutputTokens,
"second OutputTokens")
}
func TestDuckAnalyticsVelocityModelFilterUsesMatchingRowsOnly(t *testing.T) {
ctx := context.Background()
store := newDuckAnalyticsStore(t, []db.SessionBatchWrite{
{
Session: syncSession(
"duck-velocity-model", "alpha", "mixed",
"2024-06-01T09:00:00Z", 4,
),
Messages: []db.Message{
duckModelMessage(
"duck-velocity-model", 0, "user", "claude q",
"2024-06-01T09:00:00Z", "claude-3-5-sonnet",
),
duckModelMessage(
"duck-velocity-model", 1, "assistant", "offscope-offscope-xx",
"2024-06-01T09:00:10Z", "claude-3-5-sonnet",
db.ToolCall{ToolName: "Read", Category: "Read"},
db.ToolCall{ToolName: "Bash", Category: "Bash"},
db.ToolCall{ToolName: "Grep", Category: "Grep"},
),
duckModelMessage(
"duck-velocity-model", 2, "user", "gpt q",
"2024-06-01T09:10:00Z", "",
),
duckModelMessage(
"duck-velocity-model", 3, "assistant", "reply",
"2024-06-01T09:11:00Z", "gpt-4o",
db.ToolCall{ToolName: "Edit", Category: "Edit"},
db.ToolCall{ToolName: "Write", Category: "Write"},
),
},
DataVersion: 1,
ReplaceMessages: true,
},
})
resp, err := store.GetAnalyticsVelocity(ctx, db.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 TestDuckAnalyticsVelocityModelFilterCountsNullTimestampToolCallsWithoutTimeFilter(
t *testing.T,
) {
ctx := context.Background()
store := newDuckAnalyticsStore(t, []db.SessionBatchWrite{
{
Session: syncSession(
"duck-velocity-null-ts", "alpha", "mixed",
"2024-06-01T09:00:00Z", 4,
),
Messages: []db.Message{
duckModelMessage(
"duck-velocity-null-ts", 0, "user", "claude q",
"2024-06-01T09:00:00Z", "claude-3-5-sonnet",
),
duckModelMessage(
"duck-velocity-null-ts", 1, "assistant", "offscope-offscope-xx",
"2024-06-01T09:00:10Z", "claude-3-5-sonnet",
db.ToolCall{ToolName: "Read", Category: "Read"},
db.ToolCall{ToolName: "Bash", Category: "Bash"},
db.ToolCall{ToolName: "Grep", Category: "Grep"},
),
duckModelMessage(
"duck-velocity-null-ts", 2, "user", "gpt q",
"2024-06-01T09:10:00Z", "",
),
duckModelMessage(
"duck-velocity-null-ts", 3, "assistant", "reply",
"2024-06-01T09:11:00Z", "gpt-4o",
db.ToolCall{ToolName: "Edit", Category: "Edit"},
db.ToolCall{ToolName: "Write", Category: "Write"},
),
},
DataVersion: 1,
ReplaceMessages: true,
},
})
_, err := store.duck.ExecContext(ctx, `
UPDATE sessions
SET message_count = 5
WHERE id = 'duck-velocity-null-ts'`)
require.NoError(t, err, "update session message_count")
_, err = store.duck.ExecContext(ctx, `
INSERT INTO messages (
id, session_id, ordinal, role, content, timestamp,
has_tool_use, content_length, is_system, model
) VALUES
(9103, 'duck-velocity-null-ts', 4, 'assistant', 'extra',
NULL, TRUE, 5, FALSE, 'gpt-4o')`)
require.NoError(t, err, "insert null-timestamp message")
_, err = store.duck.ExecContext(ctx, `
INSERT INTO tool_calls (
id, message_id, session_id, tool_name, category, call_index
) VALUES
(9203, 9103, 'duck-velocity-null-ts', 'Search', 'Search', 0)`)
require.NoError(t, err, "insert tool call")
resp, err := store.GetAnalyticsVelocity(ctx, db.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, 3.0, resp.Overall.ToolCallsPerActiveMin,
"ToolCallsPerActiveMin")
}
func TestDuckAnalyticsSessionShapeModelFilterUsesMatchingRowsOnly(t *testing.T) {
ctx := context.Background()
store := newDuckAnalyticsStore(t, []db.SessionBatchWrite{
{
Session: syncSession(
"duck-shape-model", "alpha", "mixed",
"2024-06-01T09:00:00Z", 6,
),
Messages: []db.Message{
duckModelMessage(
"duck-shape-model", 0, "user", "gpt q",
"2024-06-01T09:00:00Z", "",
),
duckModelMessage(
"duck-shape-model", 1, "assistant", "gpt tool",
"2024-06-01T09:01:00Z", "gpt-4o",
db.ToolCall{ToolName: "Read", Category: "Read"},
),
duckModelMessage(
"duck-shape-model", 2, "user", "claude q1",
"2024-06-01T09:02:00Z", "claude-3-5-sonnet",
),
duckModelMessage(
"duck-shape-model", 3, "user", "claude q2",
"2024-06-01T09:03:00Z", "claude-3-5-sonnet",
),
duckModelMessage(
"duck-shape-model", 4, "user", "claude q3",
"2024-06-01T09:04:00Z", "claude-3-5-sonnet",
),
duckModelMessage(
"duck-shape-model", 5, "assistant", "claude reply",
"2024-06-01T09:05:00Z", "claude-3-5-sonnet",
),
},
DataVersion: 1,
ReplaceMessages: true,
},
})
resp, err := store.GetAnalyticsSessionShape(ctx, db.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 := map[string]int{}
for _, bucket := range resp.LengthDistribution {
lenMap[bucket.Label] = bucket.Count
}
assert.Equal(t, 1, lenMap["1-5"], "filtered length bucket")
assert.Equal(t, 0, lenMap["6-15"], "full-session count must not leak")
autoMap := map[string]int{}
for _, bucket := range resp.AutonomyDistribution {
autoMap[bucket.Label] = bucket.Count
}
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 TestDuckAnalyticsVelocityModelFilterUsesMatchingComplexityBucket(t *testing.T) {
ctx := context.Background()
msgs := []db.Message{
duckModelMessage(
"duck-velocity-complexity", 0, "user", "gpt q",
"2024-06-01T09:00:00Z", "",
),
duckModelMessage(
"duck-velocity-complexity", 1, "assistant", "reply",
"2024-06-01T09:01:00Z", "gpt-4o",
),
}
for i := 2; i < 16; i++ {
role := "user"
if i%2 == 1 {
role = "assistant"
}
msgs = append(msgs, duckModelMessage(
"duck-velocity-complexity", i, role, "claude",
"2024-06-01T09:10:00Z", "claude-3-5-sonnet",
))
}
store := newDuckAnalyticsStore(t, []db.SessionBatchWrite{
{
Session: syncSession(
"duck-velocity-complexity", "alpha", "mixed",
"2024-06-01T09:00:00Z", 16,
),
Messages: msgs,
DataVersion: 1,
ReplaceMessages: true,
},
})
resp, err := store.GetAnalyticsVelocity(ctx, db.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 TestDuckTrendsTermsModelFilterStaysOnMatchingMessages(t *testing.T) {
ctx := context.Background()
store := newDuckAnalyticsStore(t, []db.SessionBatchWrite{
{
Session: syncSession(
"duck-trends-model", "alpha", "mixed",
"2024-06-01T09:00:00Z", 3,
),
Messages: []db.Message{
duckModelMessage(
"duck-trends-model", 0, "user", "seam",
"2024-06-01T09:00:00Z", "",
),
duckModelMessage(
"duck-trends-model", 1, "assistant", "ready",
"2024-06-01T09:01:00Z", "gpt-4o",
),
duckModelMessage(
"duck-trends-model", 2, "assistant", "seam seam",
"2024-06-01T10:00:00Z", "claude-3-5-sonnet",
),
},
DataVersion: 1,
ReplaceMessages: true,
},
})
terms, err := db.ParseTrendTerms([]string{"seam"})
require.NoError(t, err)
resp, err := store.GetTrendsTerms(ctx, db.AnalyticsFilter{
From: "2024-06-01", To: "2024-06-01", Timezone: "UTC",
Model: "gpt-4o",
}, terms, "day")
require.NoError(t, err, "GetTrendsTerms")
assert.Equal(t, 2, resp.MessageCount, "MessageCount")
require.Len(t, resp.Series, 1, "len(Series)")
assert.Equal(t, 1, resp.Series[0].Total, "Total")
}
func newDuckAnalyticsStore(
t *testing.T, writes []db.SessionBatchWrite,
) *Store {
t.Helper()
ctx := context.Background()
local := newLocalDB(t)
_, 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 newDuckMixedModelAnalyticsStore(t *testing.T) *Store {
t.Helper()
startA := "2024-06-01T09:00:00Z"
startB := "2024-06-01T10:00:00Z"
gptMsg := duckModelMessage(
"duck-mixed-model", 0, "assistant", "seam", startA, "gpt-4o",
db.ToolCall{
ToolName: "Read", Category: "Read",
},
db.ToolCall{
ToolName: "Skill", Category: "Skill", SkillName: "review-code",
},
)
claudeMsg := duckModelMessage(
"duck-mixed-model", 1, "assistant", "seam seam", startB,
"claude-3-5-sonnet",
db.ToolCall{
ToolName: "Grep", Category: "Grep",
},
db.ToolCall{
ToolName: "Skill", Category: "Skill", SkillName: "write-tests",
},
)
return newDuckAnalyticsStore(t, []db.SessionBatchWrite{
{
Session: syncSession(
"duck-mixed-model", "alpha", "mixed", startA, 2,
),
Messages: []db.Message{
gptMsg,
claudeMsg,
},
DataVersion: 1,
ReplaceMessages: true,
},
})
}
func duckModelMessage(
sessionID string,
ordinal int,
role, content, ts, model string,
calls ...db.ToolCall,
) db.Message {
msg := syncMessage(sessionID, ordinal, role, content, ts, calls...)
msg.Model = model
return msg
}
func duckHOWMessages(cells []db.HourOfWeekCell, dow, hour int) int {
for _, cell := range cells {
if cell.DayOfWeek == dow && cell.Hour == hour {
return cell.Messages
}
}
return -1
}