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

677 lines
26 KiB
Go

//go:build pgtest
package postgres
import (
"context"
"fmt"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.kenn.io/agentsview/internal/activity"
"go.kenn.io/agentsview/internal/db"
)
// pgDayQuery 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 pgDayQuery(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
}
// seedPGDailyFixture inserts two overlapping sessions on 2026-06-16
// (UTC), each with two timestamped messages, mirroring the SQLite
// fixture in internal/db/activityreport_test.go.
func seedPGDailyFixture(t *testing.T, store *Store) {
t.Helper()
ctx := context.Background()
_, err := store.DB().ExecContext(ctx, `
INSERT INTO sessions (
id, machine, project, agent, started_at, ended_at,
message_count, user_message_count
) VALUES
('a', 'test-machine', 'proj1', 'claude',
'2026-06-16T10:00:00Z'::timestamptz,
'2026-06-16T10:02:00Z'::timestamptz, 2, 1),
('b', 'test-machine', 'proj2', 'codex',
'2026-06-16T10:01:00Z'::timestamptz,
'2026-06-16T10:03:00Z'::timestamptz, 2, 1)`)
require.NoError(t, err, "insert sessions")
_, err = store.DB().ExecContext(ctx, `
INSERT INTO messages (
session_id, ordinal, role, content, timestamp,
content_length, model
) VALUES
('a', 1, 'user', 'x',
'2026-06-16T10:00:00Z'::timestamptz, 1, ''),
('a', 2, 'assistant', 'x',
'2026-06-16T10:02:00Z'::timestamptz, 1, 'opus'),
('b', 1, 'user', 'x',
'2026-06-16T10:01:00Z'::timestamptz, 1, ''),
('b', 2, 'assistant', 'x',
'2026-06-16T10:03:00Z'::timestamptz, 1, 'gpt5')`)
require.NoError(t, err, "insert messages")
}
func TestPGGetActivityReport(t *testing.T) {
_, store := prepareUsageSchema(t, "agentsview_daily_report_test")
ctx := context.Background()
seedPGDailyFixture(t, store)
r, err := store.GetActivityReport(
ctx, db.AnalyticsFilter{Timezone: "UTC"},
pgDayQuery(t, "2026-06-16", "UTC"))
require.NoError(t, err)
assert.Equal(t, 2, r.Peak.Agents)
assert.Equal(t, 2, r.Totals.Sessions)
assert.GreaterOrEqual(t, len(r.ByModel), 2)
}
// TestPGGetActivityReportOpenSessionWithInRangeMessageIncluded 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
// DuckDB. Mirrors the SQLite
// TestGetActivityReport_OpenSessionWithInRangeMessageIncluded.
func TestPGGetActivityReportOpenSessionWithInRangeMessageIncluded(t *testing.T) {
_, store := prepareUsageSchema(t, "agentsview_daily_report_open_test")
ctx := context.Background()
// Started the day before, never closed (ended_at NULL), active in-range.
_, err := store.DB().ExecContext(ctx, `
INSERT INTO sessions (
id, machine, project, agent, started_at, ended_at,
message_count, user_message_count
) VALUES (
'open', 'test-machine', 'proj1', 'claude',
'2026-06-15T23:00:00Z'::timestamptz, NULL, 2, 1
)`)
require.NoError(t, err, "insert session")
_, err = store.DB().ExecContext(ctx, `
INSERT INTO messages (
session_id, ordinal, role, content, timestamp,
content_length, model
) VALUES
('open', 1, 'user', 'x',
'2026-06-16T10:00:00Z'::timestamptz, 1, ''),
('open', 2, 'assistant', 'x',
'2026-06-16T10:02:00Z'::timestamptz, 1, 'opus')`)
require.NoError(t, err, "insert messages")
r, err := store.GetActivityReport(
ctx, db.AnalyticsFilter{Timezone: "UTC"},
pgDayQuery(t, "2026-06-16", "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)
}
// TestPGGetActivityReportUsageCostAndTokens exercises the PG usage
// union + cost path: a single priced assistant message must surface
// its output tokens and computed cost in the day totals, matching
// the SQLite reference behavior.
func TestPGGetActivityReportUsageCostAndTokens(t *testing.T) {
_, store := prepareUsageSchema(t, "agentsview_daily_report_usage_test")
ctx := context.Background()
_, err := store.DB().ExecContext(ctx, `
INSERT INTO model_pricing (
model_pattern, input_per_mtok, output_per_mtok,
cache_creation_per_mtok, cache_read_per_mtok, updated_at
) VALUES ('claude-sonnet-4-20250514', 3, 15, 0, 0, 'seed')`)
require.NoError(t, err, "insert pricing")
_, err = store.DB().ExecContext(ctx, `
INSERT INTO sessions (
id, machine, project, agent, started_at, ended_at,
message_count, user_message_count
) VALUES (
's1', 'test-machine', 'proj1', 'claude',
'2026-06-16T10:30:00Z'::timestamptz,
'2026-06-16T10:30:00Z'::timestamptz, 1, 1
)`)
require.NoError(t, err, "insert session")
_, err = store.DB().ExecContext(ctx, `
INSERT INTO messages (
session_id, ordinal, role, content, timestamp,
content_length, model, token_usage
) VALUES (
's1', 0, 'assistant', 'x',
'2026-06-16T10:30:00Z'::timestamptz, 1,
'claude-sonnet-4-20250514',
'{"input_tokens":1000,"output_tokens":500}'
)`)
require.NoError(t, err, "insert message")
r, err := store.GetActivityReport(
ctx, db.AnalyticsFilter{Timezone: "UTC"},
pgDayQuery(t, "2026-06-16", "UTC"))
require.NoError(t, err)
assert.Equal(t, 1, r.Totals.Sessions)
assert.Equal(t, 500, r.Totals.OutputTokens)
// Cost = (1000*3 + 500*15) / 1e6 = 0.0105
assert.InDelta(t, 0.0105, r.Totals.Cost, 1e-9)
}
// TestPGGetActivityReportIncludesSubagentUsage mirrors the SQLite
// TestGetActivityReport_IncludesSubagentUsage: subagent and fork sessions
// are candidates so their usage lands in the totals (matching
// GetDailyUsage, which never filters by relationship_type). The fork's
// replayed usage row dedups away, so it adds a session row but no cost.
func TestPGGetActivityReportIncludesSubagentUsage(t *testing.T) {
_, store := prepareUsageSchema(t, "agentsview_daily_report_subagent_test")
ctx := context.Background()
_, err := store.DB().ExecContext(ctx, `
INSERT INTO model_pricing (
model_pattern, input_per_mtok, output_per_mtok,
cache_creation_per_mtok, cache_read_per_mtok, updated_at
) VALUES
('root-model', 3, 15, 0, 0, 'seed'),
('sub-model', 3, 15, 0, 0, 'seed')`)
require.NoError(t, err, "insert pricing")
_, err = store.DB().ExecContext(ctx, `
INSERT INTO sessions (
id, machine, project, agent, started_at, ended_at,
message_count, user_message_count,
parent_session_id, relationship_type
) VALUES
('root', 'test-machine', 'proj1', 'claude',
'2026-06-16T10:00:00Z'::timestamptz,
'2026-06-16T10:10:00Z'::timestamptz, 1, 1, NULL, ''),
('agent-sub', 'test-machine', 'proj1', 'claude',
'2026-06-16T10:02:00Z'::timestamptz,
'2026-06-16T10:04:00Z'::timestamptz, 1, 1, 'root', 'subagent'),
('fork', 'test-machine', 'proj1', 'claude',
'2026-06-16T10:05:00Z'::timestamptz,
'2026-06-16T10:06:00Z'::timestamptz, 1, 1, 'root', 'fork')`)
require.NoError(t, err, "insert sessions")
_, err = store.DB().ExecContext(ctx, `
INSERT INTO messages (
session_id, ordinal, role, content, timestamp,
content_length, model, token_usage,
claude_message_id, claude_request_id
) VALUES
('root', 0, 'assistant', 'x',
'2026-06-16T10:00:00Z'::timestamptz, 1, 'root-model',
'{"input_tokens":1000,"output_tokens":500}', 'm-root', 'r-root'),
('agent-sub', 0, 'assistant', 'y',
'2026-06-16T10:03:00Z'::timestamptz, 1, 'sub-model',
'{"input_tokens":2000,"output_tokens":700}', 'm-sub', 'r-sub'),
('fork', 0, 'assistant', 'x',
'2026-06-16T10:05:00Z'::timestamptz, 1, 'root-model',
'{"input_tokens":1000,"output_tokens":500}', 'm-root', 'r-root')`)
require.NoError(t, err, "insert messages")
r, err := store.GetActivityReport(
ctx, db.AnalyticsFilter{Timezone: "UTC"},
pgDayQuery(t, "2026-06-16", "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 TestPGGetActivityReportPricingModelsOnlyIncludeDedupSurvivors(t *testing.T) {
_, store := prepareUsageSchema(t, "agentsview_daily_report_pricing_survivor_test")
ctx := context.Background()
_, err := store.DB().ExecContext(ctx, `
INSERT INTO model_pricing (
model_pattern, input_per_mtok, output_per_mtok,
cache_creation_per_mtok, cache_read_per_mtok, updated_at
) VALUES
('kept-model', 3, 15, 0, 0, 'seed'),
('discarded-model', 3, 15, 0, 0, 'seed')`)
require.NoError(t, err, "insert pricing")
_, err = store.DB().ExecContext(ctx, `
INSERT INTO sessions (
id, machine, project, agent, started_at, ended_at,
message_count, user_message_count
) VALUES
('earlier', 'test-machine', 'proj1', 'claude',
'2026-06-16T10:30:00Z'::timestamptz,
'2026-06-16T10:30:00Z'::timestamptz, 1, 1),
('later', 'test-machine', 'proj1', 'claude',
'2026-06-16T10:31:00Z'::timestamptz,
'2026-06-16T10:31:00Z'::timestamptz, 1, 1)`)
require.NoError(t, err, "insert sessions")
_, err = store.DB().ExecContext(ctx, `
INSERT INTO messages (
session_id, ordinal, role, content, timestamp,
content_length, model, token_usage,
claude_message_id, claude_request_id
) VALUES
('earlier', 0, 'assistant', 'x',
'2026-06-16T10:30:00Z'::timestamptz, 1,
'kept-model',
'{"input_tokens":1000,"output_tokens":500}', 'M1', 'R1'),
('later', 0, 'assistant', 'x',
'2026-06-16T10:31:00Z'::timestamptz, 1,
'discarded-model',
'{"input_tokens":2000,"output_tokens":900}', 'M1', 'R1')`)
require.NoError(t, err, "insert messages")
r, err := store.GetActivityReport(
ctx, db.AnalyticsFilter{Timezone: "UTC"},
pgDayQuery(t, "2026-06-16", "UTC"))
require.NoError(t, err)
assert.Equal(t, 500, r.Totals.OutputTokens)
require.NotNil(t, r.Pricing)
assert.Contains(t, r.Pricing.Models, "kept-model")
assert.NotContains(t, r.Pricing.Models, "discarded-model")
}
// TestPGGetActivityReportExcludesOtherDays confirms the candidate-session
// window plus the usage ts-bounds keep a session whose only activity
// falls outside the target day from contributing to that day.
func TestPGGetActivityReportExcludesOtherDays(t *testing.T) {
_, store := prepareUsageSchema(t, "agentsview_daily_report_otherday_test")
ctx := context.Background()
_, err := store.DB().ExecContext(ctx, `
INSERT INTO sessions (
id, machine, project, agent, started_at, ended_at,
message_count, user_message_count
) VALUES
('today', 'test-machine', 'proj1', 'claude',
'2026-06-16T10:00:00Z'::timestamptz,
'2026-06-16T10:02:00Z'::timestamptz, 2, 1),
('yesterday', 'test-machine', 'proj2', 'codex',
'2026-06-10T10:00:00Z'::timestamptz,
'2026-06-10T10:02:00Z'::timestamptz, 2, 1)`)
require.NoError(t, err, "insert sessions")
_, err = store.DB().ExecContext(ctx, `
INSERT INTO messages (
session_id, ordinal, role, content, timestamp,
content_length, model
) VALUES
('today', 1, 'user', 'x',
'2026-06-16T10:00:00Z'::timestamptz, 1, ''),
('today', 2, 'assistant', 'x',
'2026-06-16T10:02:00Z'::timestamptz, 1, 'opus'),
('yesterday', 1, 'user', 'x',
'2026-06-10T10:00:00Z'::timestamptz, 1, ''),
('yesterday', 2, 'assistant', 'x',
'2026-06-10T10:02:00Z'::timestamptz, 1, 'gpt5')`)
require.NoError(t, err, "insert messages")
r, err := store.GetActivityReport(
ctx, db.AnalyticsFilter{Timezone: "UTC"},
pgDayQuery(t, "2026-06-16", "UTC"))
require.NoError(t, err)
// Only the in-day session has timed intervals on 2026-06-16.
assert.Equal(t, 1, r.Peak.Agents)
require.Len(t, r.ByAgent, 1)
assert.Equal(t, "claude", r.ByAgent[0].Key)
}
// reportSessionIDsPG collects the session IDs present in a report's
// BySession rows.
func reportSessionIDsPG(sessions []activity.SessionRow) map[string]struct{} {
out := make(map[string]struct{}, len(sessions))
for _, s := range sessions {
out[s.SessionID] = struct{}{}
}
return out
}
// TestPGGetActivityReportPriorDayWithinPadExcluded confirms the PG
// 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 in the target day's report.
func TestPGGetActivityReportPriorDayWithinPadExcluded(t *testing.T) {
_, store := prepareUsageSchema(t, "agentsview_daily_report_pad_test")
ctx := context.Background()
_, err := store.DB().ExecContext(ctx, `
INSERT INTO sessions (
id, machine, project, agent, started_at, ended_at,
message_count, user_message_count
) VALUES
('today', 'test-machine', 'proj1', 'claude',
'2026-06-16T10:00:00Z'::timestamptz,
'2026-06-16T10:02:00Z'::timestamptz, 2, 1),
('prior', 'test-machine', 'proj2', 'codex',
'2026-06-15T12:00:00Z'::timestamptz,
'2026-06-15T12:05:00Z'::timestamptz, 2, 1)`)
require.NoError(t, err, "insert sessions")
_, err = store.DB().ExecContext(ctx, `
INSERT INTO messages (
session_id, ordinal, role, content, timestamp,
content_length, model
) VALUES
('today', 1, 'user', 'x',
'2026-06-16T10:00:00Z'::timestamptz, 1, ''),
('today', 2, 'assistant', 'x',
'2026-06-16T10:02:00Z'::timestamptz, 1, 'opus')`)
require.NoError(t, err, "insert messages")
r, err := store.GetActivityReport(
ctx, db.AnalyticsFilter{Timezone: "UTC"},
pgDayQuery(t, "2026-06-16", "UTC"))
require.NoError(t, err)
ids := reportSessionIDsPG(r.BySession)
assert.Contains(t, ids, "today")
assert.NotContains(t, ids, "prior", "prior-day session must not leak in")
assert.Equal(t, 1, r.Totals.Sessions)
assert.Equal(t, 0, r.Totals.UntimedSessions)
}
// TestPGGetActivityReportExcludesIneligibleUsage confirms the PG usage
// union applies the same eligibility filters as GetDailyUsage: a
// synthetic-model message carrying real token_usage must not inflate
// the day totals.
func TestPGGetActivityReportExcludesIneligibleUsage(t *testing.T) {
_, store := prepareUsageSchema(t, "agentsview_daily_report_eligible_test")
ctx := context.Background()
_, err := store.DB().ExecContext(ctx, `
INSERT INTO model_pricing (
model_pattern, input_per_mtok, output_per_mtok,
cache_creation_per_mtok, cache_read_per_mtok, updated_at
) VALUES ('claude-sonnet-4-20250514', 3, 15, 0, 0, 'seed')`)
require.NoError(t, err, "insert pricing")
_, err = store.DB().ExecContext(ctx, `
INSERT INTO sessions (
id, machine, project, agent, started_at, ended_at,
message_count, user_message_count
) VALUES (
's1', 'test-machine', 'proj1', 'claude',
'2026-06-16T10:30:00Z'::timestamptz,
'2026-06-16T10:31:00Z'::timestamptz, 2, 1
)`)
require.NoError(t, err, "insert session")
_, err = store.DB().ExecContext(ctx, `
INSERT INTO messages (
session_id, ordinal, role, content, timestamp,
content_length, model, token_usage
) VALUES
('s1', 0, 'assistant', 'x',
'2026-06-16T10:30:00Z'::timestamptz, 1,
'claude-sonnet-4-20250514',
'{"input_tokens":1000,"output_tokens":500}'),
('s1', 1, 'assistant', 'y',
'2026-06-16T10:31:00Z'::timestamptz, 1,
'<synthetic>',
'{"input_tokens":9000,"output_tokens":7000}')`)
require.NoError(t, err, "insert messages")
r, err := store.GetActivityReport(
ctx, db.AnalyticsFilter{Timezone: "UTC"},
pgDayQuery(t, "2026-06-16", "UTC"))
require.NoError(t, err)
assert.Equal(t, 500, r.Totals.OutputTokens, "synthetic message excluded")
assert.InDelta(t, 0.0105, r.Totals.Cost, 1e-9)
}
// TestPGGetActivityReportDedupsAcrossChunks confirms the PG usage fetch's
// global re-sort across maxPGVars-sized ID chunks (activityReportUsage in
// internal/postgres/activityreport.go) orders rows by timestamp across the
// whole candidate set, not per chunk. The aggregator's first-seen-wins
// dedup relies on that order: the same (claude_message_id,
// claude_request_id) can recur in two sessions (resumed/forked) that fall
// in DIFFERENT chunks, and the globally-earliest row must survive.
//
// The candidate IDs are passed to activityReportUsage explicitly, so the
// chunk split is deterministic and never depends on PostgreSQL's scan
// order. The slice is [dup-b, 500 fillers, dup-a]: dup-b (the LATER
// timestamp) lands in the first chunk (indices 0-499) and dup-a (the
// EARLIER timestamp) in the second (indices 500-501). Only a global
// re-sort by timestamp reorders the fetched rows to [dup-a, dup-b]; a
// regression that appended per-chunk results in chunk order would yield
// [dup-b, dup-a] and fail the ordering assertion below. The fillers are
// placeholder IDs with no rows in the DB -- they exist only to push dup-a
// past the 500-variable chunk boundary.
func TestPGGetActivityReportDedupsAcrossChunks(t *testing.T) {
_, store := prepareUsageSchema(t, "agentsview_daily_report_chunk_test")
ctx := context.Background()
_, err := store.DB().ExecContext(ctx, `
INSERT INTO model_pricing (
model_pattern, input_per_mtok, output_per_mtok,
cache_creation_per_mtok, cache_read_per_mtok, updated_at
) VALUES ('claude-sonnet-4-20250514', 3, 15, 0, 0, 'seed')`)
require.NoError(t, err, "insert pricing")
// dup-a: earlier timestamp and 500 output tokens -> the correct global
// survivor of the shared (claude_message_id, claude_request_id) key.
_, err = store.DB().ExecContext(ctx, `
INSERT INTO sessions (
id, machine, project, agent, started_at, ended_at,
message_count, user_message_count
) VALUES (
'dup-a', 'test-machine', 'proj1', 'claude',
'2026-06-16T10:00:00Z'::timestamptz,
'2026-06-16T10:00:00Z'::timestamptz, 1, 1
)`)
require.NoError(t, err, "insert dup-a session")
_, err = store.DB().ExecContext(ctx, `
INSERT INTO messages (
session_id, ordinal, role, content, timestamp,
content_length, model, token_usage,
claude_message_id, claude_request_id
) VALUES (
'dup-a', 0, 'assistant', 'x',
'2026-06-16T10:00:00Z'::timestamptz, 1,
'claude-sonnet-4-20250514',
'{"input_tokens":250,"output_tokens":500}', 'M1', 'R1'
)`)
require.NoError(t, err, "insert dup-a message")
// dup-b: same dedup identity as dup-a (claude_message_id,
// claude_request_id) but a later timestamp and 900 output tokens; the
// first-seen dedup must drop it in favor of dup-a.
_, err = store.DB().ExecContext(ctx, `
INSERT INTO sessions (
id, machine, project, agent, started_at, ended_at,
message_count, user_message_count
) VALUES (
'dup-b', 'test-machine', 'proj1', 'claude',
'2026-06-16T10:05:00Z'::timestamptz,
'2026-06-16T10:05:00Z'::timestamptz, 1, 1
)`)
require.NoError(t, err, "insert dup-b session")
_, err = store.DB().ExecContext(ctx, `
INSERT INTO messages (
session_id, ordinal, role, content, timestamp,
content_length, model, token_usage,
claude_message_id, claude_request_id
) VALUES (
'dup-b', 0, 'assistant', 'x',
'2026-06-16T10:05:00Z'::timestamptz, 1,
'claude-sonnet-4-20250514',
'{"input_tokens":450,"output_tokens":900}', 'M1', 'R1'
)`)
require.NoError(t, err, "insert dup-b message")
// Build the candidate ID slice explicitly so the chunk split is
// deterministic: dup-b in chunk 1 (later ts), dup-a in chunk 2 (earlier
// ts), forcing the shared dedup identity across the maxPGVars boundary.
ids := make([]string, 0, maxPGVars+2)
ids = append(ids, "dup-b")
for i := 0; i < maxPGVars; i++ {
ids = append(ids, fmt.Sprintf("fill-%d", i))
}
ids = append(ids, "dup-a")
require.Greater(t, len(ids), maxPGVars,
"candidate IDs must exceed one chunk to span the boundary")
q := pgDayQuery(t, "2026-06-16", "UTC")
lower := paddedUTCBound(q.RangeStart.UTC().Format(time.RFC3339), -14)
upper := paddedUTCBound(q.RangeEnd.UTC().Format(time.RFC3339), 14)
usage, _, err := store.activityReportUsage(ctx, ids, lower, upper, q)
require.NoError(t, err)
// Only dup-a and dup-b carry eligible usage; the fillers have no rows, and
// the shared dedup identity leaves one survivor.
var shared []activity.UsageRow
for _, u := range usage {
if u.SessionID == "dup-a" || u.SessionID == "dup-b" {
shared = append(shared, u)
}
}
require.Len(t, shared, 1,
"only one dedup-identity row survives across the chunk boundary")
require.NotEmpty(t, shared[0].ClaudeMessageID, "rows carry a message id")
// The global re-sort by timestamp must place dup-a (10:00, fetched in
// the LATER chunk) before dup-b (10:05, fetched in the EARLIER chunk).
// A per-chunk-order regression keeps dup-b and fails here.
assert.Equal(t, "dup-a", shared[0].SessionID,
"earlier-timestamp row sorts first despite its later chunk")
assert.Equal(t, 500, shared[0].OutputTokens, "dup-a survives first-seen")
}
func TestPGGetActivityReportUsageDedupFallsBackToSourceUUID(t *testing.T) {
_, store := prepareUsageSchema(t, "agentsview_daily_report_sourceuuid_test")
ctx := context.Background()
_, err := store.DB().ExecContext(ctx, `
INSERT INTO model_pricing (
model_pattern, input_per_mtok, output_per_mtok,
cache_creation_per_mtok, cache_read_per_mtok, updated_at
) VALUES ('claude-sonnet-4-20250514', 3, 15, 0, 0, 'seed')`)
require.NoError(t, err, "insert pricing")
_, err = store.DB().ExecContext(ctx, `
INSERT INTO sessions (
id, machine, project, agent, started_at, ended_at,
message_count, user_message_count
) VALUES
('earlier', 'test-machine', 'proj1', 'claude',
'2026-06-16T10:30:00Z'::timestamptz,
'2026-06-16T10:30:00Z'::timestamptz, 1, 1),
('later', 'test-machine', 'proj2', 'claude',
'2026-06-16T10:30:01Z'::timestamptz,
'2026-06-16T10:30:01Z'::timestamptz, 1, 1)`)
require.NoError(t, err, "insert sessions")
_, err = store.DB().ExecContext(ctx, `
INSERT INTO messages (
session_id, ordinal, role, content, timestamp,
content_length, model, token_usage,
claude_message_id, source_uuid
) VALUES
('earlier', 0, 'assistant', 'x',
'2026-06-16T10:30:00Z'::timestamptz, 1,
'claude-sonnet-4-20250514',
'{"input_tokens":1000,"output_tokens":500}', 'M1', 'SRC1'),
('later', 0, 'assistant', 'x',
'2026-06-16T10:30:01Z'::timestamptz, 1,
'claude-sonnet-4-20250514',
'{"input_tokens":1000,"output_tokens":900}', 'M1', 'SRC1')`)
require.NoError(t, err, "insert messages")
r, err := store.GetActivityReport(
ctx, db.AnalyticsFilter{Timezone: "UTC"},
pgDayQuery(t, "2026-06-16", "UTC"))
require.NoError(t, err)
assert.Equal(t, 500, r.Totals.OutputTokens,
"incomplete Claude pairs fall back to source_uuid dedup in activity reports")
}
// TestPGGetActivityReportAutomationFilterAndSessionSplit confirms the shared
// AnalyticsFilter automation class is honored through the PG analytics WHERE
// builder and that the Totals carry the automated/interactive session-count
// split. Mirrors the SQLite
// TestGetActivityReport_AutomationFilterAndSessionSplit.
func TestPGGetActivityReportAutomationFilterAndSessionSplit(t *testing.T) {
_, store := prepareUsageSchema(t, "agentsview_daily_report_automation_test")
ctx := context.Background()
_, err := store.DB().ExecContext(ctx, `
INSERT INTO sessions (
id, machine, project, agent, started_at, ended_at,
message_count, user_message_count, is_automated
) VALUES
('auto1', 'test-machine', 'proj1', 'claude',
'2026-06-16T10:00:00Z'::timestamptz,
'2026-06-16T10:02:00Z'::timestamptz, 2, 1, TRUE),
('auto2', 'test-machine', 'proj1', 'claude',
'2026-06-16T11:00:00Z'::timestamptz,
'2026-06-16T11:02:00Z'::timestamptz, 2, 1, TRUE),
('human', 'test-machine', 'proj2', 'codex',
'2026-06-16T12:00:00Z'::timestamptz,
'2026-06-16T12:02:00Z'::timestamptz, 2, 1, FALSE)`)
require.NoError(t, err, "insert sessions")
_, err = store.DB().ExecContext(ctx, `
INSERT INTO messages (
session_id, ordinal, role, content, timestamp,
content_length, model
) VALUES
('auto1', 1, 'user', 'x', '2026-06-16T10:00:00Z'::timestamptz, 1, ''),
('auto1', 2, 'assistant', 'x', '2026-06-16T10:02:00Z'::timestamptz, 1, 'opus'),
('auto2', 1, 'user', 'x', '2026-06-16T11:00:00Z'::timestamptz, 1, ''),
('auto2', 2, 'assistant', 'x', '2026-06-16T11:02:00Z'::timestamptz, 1, 'opus'),
('human', 1, 'user', 'x', '2026-06-16T12:00:00Z'::timestamptz, 1, ''),
('human', 2, 'assistant', 'x', '2026-06-16T12:02:00Z'::timestamptz, 1, 'gpt5')`)
require.NoError(t, err, "insert messages")
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,
pgDayQuery(t, "2026-06-16", "UTC"))
require.NoError(t, err)
assert.Equal(t, len(tc.wantIDs), r.Totals.Sessions)
assert.Equal(t, tc.wantAutomated, r.Totals.AutomatedSessions)
assert.Equal(t, tc.wantInteractive, r.Totals.InteractiveSessions)
ids := reportSessionIDsPG(r.BySession)
require.Len(t, ids, len(tc.wantIDs))
for _, id := range tc.wantIDs {
assert.Contains(t, ids, id)
}
})
}
}