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
640 lines
24 KiB
Go
640 lines
24 KiB
Go
//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 == '<synthetic>', 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>"
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|