f99010fae1
Desktop Artifacts / Desktop Build (Linux) (push) Waiting to run
Desktop Artifacts / Desktop Build (Windows) (push) Waiting to run
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Waiting to run
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
1107 lines
40 KiB
Go
1107 lines
40 KiB
Go
//go:build pgtest
|
|
|
|
package postgres
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"go.kenn.io/agentsview/internal/db"
|
|
)
|
|
|
|
func prepareUsageSchema(
|
|
t *testing.T, schema string,
|
|
) (string, *Store) {
|
|
t.Helper()
|
|
|
|
pgURL := testPGURL(t)
|
|
pg, err := Open(pgURL, schema, true)
|
|
require.NoError(t, err, "Open")
|
|
t.Cleanup(func() { _ = pg.Close() })
|
|
|
|
ctx := context.Background()
|
|
_, err = pg.Exec(`DROP SCHEMA IF EXISTS ` + schema + ` CASCADE`)
|
|
require.NoError(t, err, "drop schema")
|
|
require.NoError(t, EnsureSchema(ctx, pg, schema), "EnsureSchema")
|
|
|
|
store, err := NewStore(pgURL, schema, true)
|
|
require.NoError(t, err, "NewStore")
|
|
t.Cleanup(func() { _ = store.Close() })
|
|
return pgURL, store
|
|
}
|
|
|
|
func TestStoreGetDailyUsageUsesFallbackPricing(t *testing.T) {
|
|
_, store := prepareUsageSchema(t, "agentsview_usage_fallback_test")
|
|
|
|
ctx := context.Background()
|
|
_, err := store.DB().ExecContext(ctx, `
|
|
INSERT INTO sessions (
|
|
id, machine, project, agent, started_at,
|
|
message_count, user_message_count
|
|
) VALUES (
|
|
'usage-fallback-001', 'test-machine', 'proj', 'claude',
|
|
'2026-03-12T10:00: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 (
|
|
'usage-fallback-001', 0, 'assistant', 'hi',
|
|
'2026-03-12T10:00:00Z'::timestamptz, 2,
|
|
'claude-sonnet-4-20250514',
|
|
'{"input_tokens":1000000}'
|
|
)`)
|
|
require.NoError(t, err, "insert message")
|
|
|
|
result, err := store.GetDailyUsage(ctx, db.UsageFilter{
|
|
From: "2026-03-12",
|
|
To: "2026-03-12",
|
|
Timezone: "UTC",
|
|
})
|
|
require.NoError(t, err, "GetDailyUsage")
|
|
assert.Equal(t, 3.0, result.Totals.TotalCost)
|
|
assert.Len(t, result.Daily, 1)
|
|
}
|
|
|
|
func TestStoreGetDailyUsageWithBreakdowns(t *testing.T) {
|
|
_, store := prepareUsageSchema(t, "agentsview_usage_breakdown_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
|
|
('test-model-a', 1, 2, 3, 0.5, 'seed'),
|
|
('test-model-b', 2, 4, 0, 0, 'seed')`)
|
|
require.NoError(t, err, "insert pricing")
|
|
_, err = store.DB().ExecContext(ctx, `
|
|
INSERT INTO sessions (
|
|
id, machine, project, agent, started_at,
|
|
message_count, user_message_count
|
|
) VALUES
|
|
('usage-breakdown-001', 'test-machine', 'proj-a', 'claude',
|
|
'2026-03-12T10:00:00Z'::timestamptz, 1, 1),
|
|
('usage-breakdown-002', 'test-machine', 'proj-b', 'codex',
|
|
'2026-03-12T11:00: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
|
|
) VALUES
|
|
('usage-breakdown-001', 0, 'assistant', 'one',
|
|
'2026-03-12T10:00:00Z'::timestamptz, 3,
|
|
'test-model-a',
|
|
'{"input_tokens":1000000,"output_tokens":500000,"cache_creation_input_tokens":250000,"cache_read_input_tokens":250000}'),
|
|
('usage-breakdown-002', 0, 'assistant', 'two',
|
|
'2026-03-12T11:00:00Z'::timestamptz, 3,
|
|
'test-model-b',
|
|
'{"input_tokens":500000,"output_tokens":250000}')`)
|
|
require.NoError(t, err, "insert messages")
|
|
|
|
result, err := store.GetDailyUsage(ctx, db.UsageFilter{
|
|
From: "2026-03-12",
|
|
To: "2026-03-12",
|
|
Timezone: "UTC",
|
|
Breakdowns: true,
|
|
})
|
|
require.NoError(t, err, "GetDailyUsage")
|
|
require.Len(t, result.Daily, 1)
|
|
day := result.Daily[0]
|
|
assert.Equal(t, 1500000, day.InputTokens)
|
|
assert.Equal(t, 750000, day.OutputTokens)
|
|
assert.Len(t, day.ProjectBreakdowns, 2)
|
|
assert.Len(t, day.AgentBreakdowns, 2)
|
|
assert.Len(t, day.ModelBreakdowns, 2)
|
|
assert.Greater(t, day.TotalCost, 0.0)
|
|
|
|
noCounts, err := store.GetDailyUsage(ctx, db.UsageFilter{
|
|
From: "2026-03-12",
|
|
To: "2026-03-12",
|
|
Timezone: "UTC",
|
|
SkipSessionCounts: true,
|
|
})
|
|
require.NoError(t, err, "GetDailyUsage skip session counts")
|
|
assert.Equal(t, result.Totals.InputTokens, noCounts.Totals.InputTokens)
|
|
assert.Zero(t, noCounts.SessionCounts.Total)
|
|
assert.Nil(t, noCounts.SessionCounts.ByProject)
|
|
assert.Nil(t, noCounts.SessionCounts.ByAgent)
|
|
}
|
|
|
|
func TestStoreGetDailyUsageDedupesBySourceUUIDWhenClaudePairIncomplete(t *testing.T) {
|
|
_, store := prepareUsageSchema(t, "agentsview_usage_source_uuid_daily_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 ('test-model-source-daily', 1, 2, 3, 0.5, 'seed')`)
|
|
require.NoError(t, err, "insert pricing")
|
|
_, err = store.DB().ExecContext(ctx, `
|
|
INSERT INTO sessions (
|
|
id, machine, project, agent, started_at,
|
|
message_count, user_message_count
|
|
) VALUES
|
|
('usage-source-daily-001', 'test-machine', 'proj-a', 'claude',
|
|
'2026-03-12T10:00:00Z'::timestamptz, 1, 1),
|
|
('usage-source-daily-002', 'test-machine', 'proj-b', 'claude',
|
|
'2026-03-12T10:01: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, source_uuid
|
|
) VALUES
|
|
('usage-source-daily-001', 0, 'assistant', 'one',
|
|
'2026-03-12T10:00:00Z'::timestamptz, 3,
|
|
'test-model-source-daily',
|
|
'{"input_tokens":1000000,"output_tokens":500000,"cache_creation_input_tokens":250000,"cache_read_input_tokens":250000}',
|
|
'msg-1', '', 'source-1'),
|
|
('usage-source-daily-002', 0, 'assistant', 'two',
|
|
'2026-03-12T10:01:00Z'::timestamptz, 3,
|
|
'test-model-source-daily',
|
|
'{"input_tokens":1000000,"output_tokens":500000,"cache_creation_input_tokens":250000,"cache_read_input_tokens":250000}',
|
|
'msg-1', '', 'source-1')`)
|
|
require.NoError(t, err, "insert messages")
|
|
|
|
result, err := store.GetDailyUsage(ctx, db.UsageFilter{
|
|
From: "2026-03-12",
|
|
To: "2026-03-12",
|
|
Timezone: "UTC",
|
|
})
|
|
require.NoError(t, err, "GetDailyUsage")
|
|
require.Len(t, result.Daily, 1)
|
|
assert.Equal(t, 1000000, result.Daily[0].InputTokens)
|
|
assert.Equal(t, 500000, result.Daily[0].OutputTokens)
|
|
}
|
|
|
|
func TestStoreGetSessionUsagePricedModel(t *testing.T) {
|
|
_, store := prepareUsageSchema(t, "agentsview_session_usage_priced_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 ('gpt-5.1', 3, 15, 3.75, 0.30, 'seed')`)
|
|
require.NoError(t, err, "insert pricing")
|
|
_, err = store.DB().ExecContext(ctx, `
|
|
INSERT INTO sessions (
|
|
id, machine, project, agent, started_at,
|
|
message_count, user_message_count,
|
|
total_output_tokens, peak_context_tokens,
|
|
has_total_output_tokens, has_peak_context_tokens
|
|
) VALUES (
|
|
'codex:usage-priced', 'test-machine', 'my-project', 'codex',
|
|
'2026-03-12T10:00:00Z'::timestamptz, 2, 1,
|
|
1234, 56789, true, true
|
|
)`)
|
|
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 (
|
|
'codex:usage-priced', 1, 'assistant', 'done',
|
|
'2026-03-12T10:01:00Z'::timestamptz, 4,
|
|
'gpt-5.1',
|
|
'{"input_tokens":1000,"output_tokens":500,"cache_creation_input_tokens":200,"cache_read_input_tokens":300}'
|
|
)`)
|
|
require.NoError(t, err, "insert message")
|
|
|
|
got, err := store.GetSessionUsage(ctx, "codex:usage-priced", true)
|
|
require.NoError(t, err, "GetSessionUsage")
|
|
require.NotNil(t, got, "GetSessionUsage result")
|
|
assert.Equal(t, "codex:usage-priced", got.SessionID)
|
|
assert.Equal(t, "codex", got.Agent)
|
|
assert.Equal(t, "my-project", got.Project)
|
|
assert.Equal(t, 1234, got.TotalOutputTokens)
|
|
assert.Equal(t, 56789, got.PeakContextTokens)
|
|
assert.True(t, got.HasTokenData)
|
|
assert.True(t, got.HasCost)
|
|
assert.InDelta(t, 0.01134, got.CostUSD, 1e-9)
|
|
assert.Equal(t, []string{"gpt-5.1"}, got.Models)
|
|
assert.Empty(t, got.UnpricedModels)
|
|
require.Len(t, got.Breakdown, 1, "Breakdown")
|
|
entry := got.Breakdown[0]
|
|
assert.Equal(t, 1, entry.Ordinal)
|
|
require.NotNil(t, entry.MessageOrdinal)
|
|
assert.Equal(t, 1, *entry.MessageOrdinal)
|
|
assert.Equal(t, "message", entry.Source)
|
|
assert.Equal(t, "Prompt 2", entry.Label)
|
|
assert.Equal(t, "2026-03-12T10:01:00Z", entry.Timestamp)
|
|
assert.Equal(t, "gpt-5.1", entry.Model)
|
|
assert.Equal(t, 1000, entry.InputTokens)
|
|
assert.Equal(t, 500, entry.OutputTokens)
|
|
assert.Equal(t, 200, entry.CacheCreationInputTokens)
|
|
assert.Equal(t, 300, entry.CacheReadInputTokens)
|
|
assert.True(t, entry.HasCost)
|
|
assert.InDelta(t, 0.01134, entry.CostUSD, 1e-9)
|
|
}
|
|
|
|
func TestStoreGetSessionUsageDedupesSourceUUIDWhenClaudePairIncomplete(t *testing.T) {
|
|
_, store := prepareUsageSchema(t, "agentsview_session_usage_source_uuid_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-opus-4-6', 5, 25, 6.25, 0.5, 'seed')`)
|
|
require.NoError(t, err, "insert pricing")
|
|
_, err = store.DB().ExecContext(ctx, `
|
|
INSERT INTO sessions (
|
|
id, machine, project, agent, started_at,
|
|
message_count, user_message_count
|
|
) VALUES (
|
|
'claude:usage-source', 'test-machine', 'proj', 'claude-code',
|
|
'2026-03-12T10:00: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, claude_message_id, claude_request_id, source_uuid
|
|
) VALUES
|
|
('claude:usage-source', 0, 'assistant', 'one',
|
|
'2026-03-12T10:00:00Z'::timestamptz, 3,
|
|
'claude-opus-4-6', '{"input_tokens":1000,"output_tokens":500}',
|
|
'msg-1', '', 'source-1'),
|
|
('claude:usage-source', 1, 'assistant', 'two',
|
|
'2026-03-12T10:01:00Z'::timestamptz, 3,
|
|
'claude-opus-4-6', '{"input_tokens":1000,"output_tokens":500}',
|
|
'msg-1', '', 'source-1')`)
|
|
require.NoError(t, err, "insert messages")
|
|
|
|
got, err := store.GetSessionUsage(ctx, "claude:usage-source", true)
|
|
require.NoError(t, err, "GetSessionUsage")
|
|
require.NotNil(t, got, "GetSessionUsage result")
|
|
assert.True(t, got.HasCost)
|
|
assert.InDelta(t, 0.0175, got.CostUSD, 1e-9)
|
|
assert.Equal(t, []string{"claude-opus-4-6"}, got.Models)
|
|
require.Len(t, got.Breakdown, 1, "Breakdown")
|
|
require.NotNil(t, got.Breakdown[0].MessageOrdinal)
|
|
assert.Equal(t, 0, *got.Breakdown[0].MessageOrdinal)
|
|
}
|
|
|
|
func TestStoreGetSessionUsageNoTokenRowsKeepsMetadata(t *testing.T) {
|
|
_, store := prepareUsageSchema(t, "agentsview_session_usage_empty_test")
|
|
|
|
ctx := context.Background()
|
|
_, err := store.DB().ExecContext(ctx, `
|
|
INSERT INTO sessions (
|
|
id, machine, project, agent, started_at,
|
|
message_count, user_message_count
|
|
) VALUES (
|
|
'codex:usage-empty', 'test-machine', 'quiet-project', 'codex',
|
|
'2026-03-12T10:00:00Z'::timestamptz, 1, 1
|
|
)`)
|
|
require.NoError(t, err, "insert session")
|
|
|
|
got, err := store.GetSessionUsage(ctx, "codex:usage-empty", true)
|
|
require.NoError(t, err, "GetSessionUsage")
|
|
require.NotNil(t, got, "GetSessionUsage result")
|
|
assert.Equal(t, "codex:usage-empty", got.SessionID)
|
|
assert.Equal(t, "codex", got.Agent)
|
|
assert.Equal(t, "quiet-project", got.Project)
|
|
assert.False(t, got.HasTokenData)
|
|
assert.False(t, got.HasCost)
|
|
assert.Zero(t, got.CostUSD)
|
|
assert.Empty(t, got.Models)
|
|
assert.Empty(t, got.UnpricedModels)
|
|
assert.Empty(t, got.Breakdown)
|
|
}
|
|
|
|
func TestStoreGetSessionUsageNotFound(t *testing.T) {
|
|
_, store := prepareUsageSchema(t, "agentsview_session_usage_missing_test")
|
|
|
|
got, err := store.GetSessionUsage(context.Background(), "missing", true)
|
|
require.NoError(t, err, "GetSessionUsage")
|
|
assert.Nil(t, got, "GetSessionUsage")
|
|
}
|
|
|
|
func TestStoreGetTopSessionsByCostDedupesClaudeKeys(t *testing.T) {
|
|
_, store := prepareUsageSchema(t, "agentsview_usage_top_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 ('test-model-top', 1, 0, 0, 0, 'seed')`)
|
|
require.NoError(t, err, "insert pricing")
|
|
_, err = store.DB().ExecContext(ctx, `
|
|
INSERT INTO sessions (
|
|
id, machine, project, agent, started_at,
|
|
message_count, user_message_count
|
|
) VALUES
|
|
('usage-top-001', 'test-machine', 'proj-a', 'claude',
|
|
'2026-03-12T10:00:00Z'::timestamptz, 1, 1),
|
|
('usage-top-002', 'test-machine', 'proj-b', 'claude',
|
|
'2026-03-12T10:01: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
|
|
('usage-top-001', 0, 'assistant', 'one',
|
|
'2026-03-12T10:00:00Z'::timestamptz, 3,
|
|
'test-model-top', '{"input_tokens":1000000}', 'msg-1', 'req-1'),
|
|
('usage-top-002', 0, 'assistant', 'two',
|
|
'2026-03-12T10:01:00Z'::timestamptz, 3,
|
|
'test-model-top', '{"input_tokens":1000000}', 'msg-1', 'req-1')`)
|
|
require.NoError(t, err, "insert messages")
|
|
|
|
top, err := store.GetTopSessionsByCost(ctx, db.UsageFilter{
|
|
From: "2026-03-12",
|
|
To: "2026-03-12",
|
|
Timezone: "UTC",
|
|
}, 20)
|
|
require.NoError(t, err, "GetTopSessionsByCost")
|
|
require.Len(t, top, 1)
|
|
assert.Equal(t, "usage-top-001", top[0].SessionID)
|
|
}
|
|
|
|
func TestStoreGetTopSessionsByCostDedupesSourceUUIDFallback(t *testing.T) {
|
|
_, store := prepareUsageSchema(t, "agentsview_usage_top_source_uuid_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 ('test-model-top-source', 1, 0, 0, 0, 'seed')`)
|
|
require.NoError(t, err, "insert pricing")
|
|
_, err = store.DB().ExecContext(ctx, `
|
|
INSERT INTO sessions (
|
|
id, machine, project, agent, started_at,
|
|
message_count, user_message_count
|
|
) VALUES
|
|
('usage-top-source-001', 'test-machine', 'proj-a', 'claude',
|
|
'2026-03-12T10:00:00Z'::timestamptz, 1, 1),
|
|
('usage-top-source-002', 'test-machine', 'proj-b', 'claude',
|
|
'2026-03-12T10:01: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, source_uuid
|
|
) VALUES
|
|
('usage-top-source-001', 0, 'assistant', 'one',
|
|
'2026-03-12T10:00:00Z'::timestamptz, 3,
|
|
'test-model-top-source', '{"input_tokens":1000000}', 'msg-1', '', 'source-1'),
|
|
('usage-top-source-002', 0, 'assistant', 'two',
|
|
'2026-03-12T10:01:00Z'::timestamptz, 3,
|
|
'test-model-top-source', '{"input_tokens":1000000}', 'msg-1', '', 'source-1')`)
|
|
require.NoError(t, err, "insert messages")
|
|
|
|
top, err := store.GetTopSessionsByCost(ctx, db.UsageFilter{
|
|
From: "2026-03-12",
|
|
To: "2026-03-12",
|
|
Timezone: "UTC",
|
|
}, 20)
|
|
require.NoError(t, err, "GetTopSessionsByCost")
|
|
require.Len(t, top, 1)
|
|
assert.Equal(t, "usage-top-source-001", top[0].SessionID)
|
|
}
|
|
|
|
func TestStoreGetUsageSessionCountsDedupesClaudeKeys(t *testing.T) {
|
|
_, store := prepareUsageSchema(t, "agentsview_usage_counts_test")
|
|
|
|
ctx := context.Background()
|
|
_, err := store.DB().ExecContext(ctx, `
|
|
INSERT INTO sessions (
|
|
id, machine, project, agent, started_at,
|
|
message_count, user_message_count
|
|
) VALUES
|
|
('usage-counts-001', 'test-machine', 'proj-a', 'claude',
|
|
'2026-03-12T10:00:00Z'::timestamptz, 1, 1),
|
|
('usage-counts-002', 'test-machine', 'proj-b', 'claude',
|
|
'2026-03-12T10:01: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
|
|
('usage-counts-001', 0, 'assistant', 'one',
|
|
'2026-03-12T10:00:00Z'::timestamptz, 3,
|
|
'test-model-counts', '{"input_tokens":1}', 'msg-1', 'req-1'),
|
|
('usage-counts-002', 0, 'assistant', 'two',
|
|
'2026-03-12T10:01:00Z'::timestamptz, 3,
|
|
'test-model-counts', '{"input_tokens":1}', 'msg-1', 'req-1')`)
|
|
require.NoError(t, err, "insert messages")
|
|
|
|
counts, err := store.GetUsageSessionCounts(ctx, db.UsageFilter{
|
|
From: "2026-03-12",
|
|
To: "2026-03-12",
|
|
Timezone: "UTC",
|
|
})
|
|
require.NoError(t, err, "GetUsageSessionCounts")
|
|
assert.Equal(t, 1, counts.Total)
|
|
assert.Equal(t, 1, counts.ByProject["proj-a"])
|
|
_, ok := counts.ByProject["proj-b"]
|
|
assert.False(t, ok, "proj-b should have been deduped out: %#v", counts.ByProject)
|
|
}
|
|
|
|
func TestStoreGetUsageMatchingSessionCountCountsCopilotSessionsWithoutUsageRows(
|
|
t *testing.T,
|
|
) {
|
|
_, store := prepareUsageSchema(t, "agentsview_usage_matching_sessions_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
|
|
('copilot-empty', 'test-machine', 'proj-a', 'copilot',
|
|
'2026-03-12T10:00:00Z'::timestamptz,
|
|
'2026-03-12T10:00:00Z'::timestamptz, 1, 1),
|
|
('claude-usage', 'test-machine', 'proj-a', 'claude',
|
|
'2026-03-12T11:00:00Z'::timestamptz,
|
|
'2026-03-12T11:00: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
|
|
) VALUES
|
|
('copilot-empty', 0, 'assistant', 'copilot',
|
|
'2026-03-12T10:00:00Z'::timestamptz, 7,
|
|
'gpt-5.3-codex', ''),
|
|
('claude-usage', 0, 'assistant', 'claude',
|
|
'2026-03-12T11:00:00Z'::timestamptz, 6,
|
|
'claude-sonnet-4-20250514', '{"input_tokens":1}')`)
|
|
require.NoError(t, err, "insert messages")
|
|
|
|
count, err := store.GetUsageMatchingSessionCount(ctx, db.UsageFilter{
|
|
From: "2026-03-12",
|
|
To: "2026-03-12",
|
|
Timezone: "UTC",
|
|
Agent: "copilot",
|
|
Model: "gpt-5.3-codex",
|
|
})
|
|
require.NoError(t, err, "GetUsageMatchingSessionCount")
|
|
assert.Equal(t, 1, count)
|
|
}
|
|
|
|
func TestStoreGetUsageMatchingSessionCountCountsCopilotSessionByMessageTimestampOutsideSessionWindow(
|
|
t *testing.T,
|
|
) {
|
|
_, store := prepareUsageSchema(t, "agentsview_usage_matching_sessions_late_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
|
|
('copilot-late-message', 'test-machine', 'proj-a', 'copilot',
|
|
'2026-02-08T10:00:00Z'::timestamptz,
|
|
'2026-02-08T10:00:00Z'::timestamptz, 1, 1),
|
|
('copilot-out-of-range', 'test-machine', 'proj-a', 'copilot',
|
|
'2026-02-08T10:00:00Z'::timestamptz,
|
|
'2026-02-08T10:00: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
|
|
) VALUES
|
|
('copilot-late-message', 0, 'assistant', 'copilot',
|
|
'2026-02-10T12:00:00Z'::timestamptz, 7,
|
|
'gpt-5.3-codex', ''),
|
|
('copilot-out-of-range', 0, 'assistant', 'copilot',
|
|
'2026-02-08T10:00:00Z'::timestamptz, 7,
|
|
'gpt-5.3-codex', '')`)
|
|
require.NoError(t, err, "insert messages")
|
|
|
|
count, err := store.GetUsageMatchingSessionCount(ctx, db.UsageFilter{
|
|
From: "2026-02-10",
|
|
To: "2026-02-10",
|
|
Timezone: "UTC",
|
|
Agent: "copilot",
|
|
})
|
|
require.NoError(t, err, "GetUsageMatchingSessionCount")
|
|
assert.Equal(t, 1, count)
|
|
}
|
|
|
|
// TestStoreGetUsageMatchingSessionCountModelFilterAppliesToBoundedRow
|
|
// guards against the model/exclude-model predicate matching session-wide
|
|
// instead of on the in-range message row: a session with an out-of-range
|
|
// message on the filtered model but an in-range message on a different
|
|
// model must not match a Model filter for the out-of-range model.
|
|
func TestStoreGetUsageMatchingSessionCountModelFilterAppliesToBoundedRow(
|
|
t *testing.T,
|
|
) {
|
|
_, store := prepareUsageSchema(t, "agentsview_usage_matching_sessions_model_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
|
|
('copilot-mixed-model', 'test-machine', 'proj-a', 'copilot',
|
|
'2026-02-08T10:00:00Z'::timestamptz,
|
|
'2026-02-10T12:00: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, token_usage
|
|
) VALUES
|
|
('copilot-mixed-model', 0, 'assistant', 'copilot',
|
|
'2026-02-08T10:00:00Z'::timestamptz, 7,
|
|
'gpt-5.3-codex', ''),
|
|
('copilot-mixed-model', 1, 'assistant', 'claude',
|
|
'2026-02-10T12:00:00Z'::timestamptz, 6,
|
|
'claude-sonnet', '')`)
|
|
require.NoError(t, err, "insert messages")
|
|
|
|
count, err := store.GetUsageMatchingSessionCount(ctx, db.UsageFilter{
|
|
From: "2026-02-10",
|
|
To: "2026-02-10",
|
|
Timezone: "UTC",
|
|
Agent: "copilot",
|
|
Model: "gpt-5.3-codex",
|
|
})
|
|
require.NoError(t, err, "GetUsageMatchingSessionCount")
|
|
assert.Equal(t, 0, count,
|
|
"out-of-range message's model must not match the bounded window")
|
|
}
|
|
|
|
// TestStoreGetUsageMatchingSessionCountCountsAssistantMessageWithNoModel
|
|
// guards against gating matching-session eligibility on m.model != ”:
|
|
// some Copilot assistant messages parse before a model name is known, so
|
|
// an assistant message with an empty model must still count toward the
|
|
// matching-session total when no Model/ExcludeModel filter narrows it.
|
|
func TestStoreGetUsageMatchingSessionCountCountsAssistantMessageWithNoModel(
|
|
t *testing.T,
|
|
) {
|
|
_, store := prepareUsageSchema(t, "agentsview_usage_matching_sessions_no_model_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
|
|
('copilot-no-model', 'test-machine', 'proj-a', 'copilot',
|
|
'2026-02-10T10:00:00Z'::timestamptz,
|
|
'2026-02-10T10:00: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
|
|
) VALUES
|
|
('copilot-no-model', 0, 'assistant', 'copilot',
|
|
'2026-02-10T10:00:00Z'::timestamptz, 7,
|
|
'', '')`)
|
|
require.NoError(t, err, "insert messages")
|
|
|
|
count, err := store.GetUsageMatchingSessionCount(ctx, db.UsageFilter{
|
|
From: "2026-02-10",
|
|
To: "2026-02-10",
|
|
Timezone: "UTC",
|
|
Agent: "copilot",
|
|
})
|
|
require.NoError(t, err, "GetUsageMatchingSessionCount")
|
|
assert.Equal(t, 1, count,
|
|
"assistant message with no model must still count without a model filter")
|
|
}
|
|
|
|
// TestStoreGetUsageMatchingSessionCountUnboundedMatchesBoundedSemantics
|
|
// guards against the unbounded (no From/To) branch drifting from the
|
|
// bounded branch: soft-deleted sessions and sessions without
|
|
// assistant/event activity must not count, and empty-model assistant
|
|
// messages must survive an ExcludeModel filter, exactly as on the
|
|
// bounded path.
|
|
func TestStoreGetUsageMatchingSessionCountUnboundedMatchesBoundedSemantics(
|
|
t *testing.T,
|
|
) {
|
|
_, store := prepareUsageSchema(t, "agentsview_usage_matching_sessions_unbounded_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, deleted_at
|
|
) VALUES
|
|
('copilot-live', 'test-machine', 'proj-a', 'copilot',
|
|
'2026-03-01T10:00:00Z'::timestamptz,
|
|
'2026-03-01T10:00:00Z'::timestamptz, 1, 1, NULL),
|
|
('copilot-trashed', 'test-machine', 'proj-a', 'copilot',
|
|
'2026-03-01T10:00:00Z'::timestamptz,
|
|
'2026-03-01T10:00:00Z'::timestamptz, 1, 1,
|
|
'2026-03-02T00:00:00Z'::timestamptz),
|
|
('copilot-user-only', 'test-machine', 'proj-a', 'copilot',
|
|
'2026-03-01T10:00:00Z'::timestamptz,
|
|
'2026-03-01T10:00:00Z'::timestamptz, 1, 1, NULL)`)
|
|
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
|
|
) VALUES
|
|
('copilot-live', 0, 'assistant', 'copilot',
|
|
'2026-03-01T10:00:00Z'::timestamptz, 7,
|
|
'', ''),
|
|
('copilot-trashed', 0, 'assistant', 'copilot',
|
|
'2026-03-01T10:00:00Z'::timestamptz, 7,
|
|
'gpt-5.3-codex', ''),
|
|
('copilot-user-only', 0, 'user', 'hello',
|
|
'2026-03-01T10:00:00Z'::timestamptz, 5,
|
|
'', '')`)
|
|
require.NoError(t, err, "insert messages")
|
|
|
|
unbounded := db.UsageFilter{Timezone: "UTC", Agent: "copilot"}
|
|
count, err := store.GetUsageMatchingSessionCount(ctx, unbounded)
|
|
require.NoError(t, err, "GetUsageMatchingSessionCount unbounded")
|
|
assert.Equal(t, 1, count,
|
|
"soft-deleted and assistant-less sessions must not count unbounded")
|
|
|
|
bounded := unbounded
|
|
bounded.From = "2026-03-01"
|
|
bounded.To = "2026-03-01"
|
|
boundedCount, err := store.GetUsageMatchingSessionCount(ctx, bounded)
|
|
require.NoError(t, err, "GetUsageMatchingSessionCount bounded")
|
|
assert.Equal(t, count, boundedCount,
|
|
"bounded and unbounded requests must match the same sessions")
|
|
|
|
excluded := unbounded
|
|
excluded.ExcludeModel = "gpt-5.3-codex"
|
|
excludedCount, err := store.GetUsageMatchingSessionCount(ctx, excluded)
|
|
require.NoError(t, err, "GetUsageMatchingSessionCount exclude-model")
|
|
assert.Equal(t, 1, excludedCount,
|
|
"empty-model assistant message must survive an ExcludeModel filter")
|
|
}
|
|
|
|
func TestStoreGetUsageSessionCountsDedupesSourceUUIDFallback(t *testing.T) {
|
|
_, store := prepareUsageSchema(t, "agentsview_usage_counts_source_uuid_test")
|
|
|
|
ctx := context.Background()
|
|
_, err := store.DB().ExecContext(ctx, `
|
|
INSERT INTO sessions (
|
|
id, machine, project, agent, started_at,
|
|
message_count, user_message_count
|
|
) VALUES
|
|
('usage-counts-source-001', 'test-machine', 'proj-a', 'claude',
|
|
'2026-03-12T10:00:00Z'::timestamptz, 1, 1),
|
|
('usage-counts-source-002', 'test-machine', 'proj-b', 'claude',
|
|
'2026-03-12T10:01: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, source_uuid
|
|
) VALUES
|
|
('usage-counts-source-001', 0, 'assistant', 'one',
|
|
'2026-03-12T10:00:00Z'::timestamptz, 3,
|
|
'test-model-counts', '{"input_tokens":1}', 'msg-1', '', 'source-1'),
|
|
('usage-counts-source-002', 0, 'assistant', 'two',
|
|
'2026-03-12T10:01:00Z'::timestamptz, 3,
|
|
'test-model-counts', '{"input_tokens":1}', 'msg-1', '', 'source-1')`)
|
|
require.NoError(t, err, "insert messages")
|
|
|
|
counts, err := store.GetUsageSessionCounts(ctx, db.UsageFilter{
|
|
From: "2026-03-12",
|
|
To: "2026-03-12",
|
|
Timezone: "UTC",
|
|
})
|
|
require.NoError(t, err, "GetUsageSessionCounts")
|
|
assert.Equal(t, 1, counts.Total)
|
|
assert.Equal(t, 1, counts.ByProject["proj-a"])
|
|
_, ok := counts.ByProject["proj-b"]
|
|
assert.False(t, ok, "proj-b should have been deduped out: %#v", counts.ByProject)
|
|
}
|
|
|
|
func TestPostgresUsageQueriesUnionUsageEvents(t *testing.T) {
|
|
_, store := prepareUsageSchema(t, "agentsview_usage_events_union_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', 1, 1, 1, 1, 'seed'),
|
|
('gpt-5.4', 1, 1, 1, 1, 'seed')`)
|
|
require.NoError(t, err, "insert pricing")
|
|
_, err = store.DB().ExecContext(ctx, `
|
|
INSERT INTO sessions (
|
|
id, machine, project, agent, started_at,
|
|
message_count, user_message_count
|
|
) VALUES
|
|
('claude-msg', 'test-machine', 'proj-a', 'claude',
|
|
'2026-05-14T09:00:00Z'::timestamptz, 1, 1),
|
|
('hermes-event', 'test-machine', 'proj-b', 'hermes',
|
|
'2026-05-14T10:00:00Z'::timestamptz, 1, 1),
|
|
('hermes-event-2', 'test-machine', 'proj-b', 'hermes',
|
|
'2026-05-14T10:10: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
|
|
) VALUES
|
|
('claude-msg', 0, 'assistant', 'one',
|
|
'2026-05-14T09:05:00Z'::timestamptz, 3,
|
|
'claude-sonnet-4-20250514',
|
|
'{"input_tokens":100,"output_tokens":40}')`)
|
|
require.NoError(t, err, "insert message")
|
|
_, err = store.DB().ExecContext(ctx, `
|
|
INSERT INTO usage_events (
|
|
session_id, source, model, input_tokens, output_tokens,
|
|
cache_read_input_tokens, occurred_at, dedup_key
|
|
) VALUES
|
|
('hermes-event', 'session', 'gpt-5.4', 300, 70, 20,
|
|
'2026-05-14T10:05:00Z'::timestamptz, 'shared-key'),
|
|
('hermes-event-2', 'session', 'gpt-5.4', 50, 5, 0,
|
|
'2026-05-14T10:10:00Z'::timestamptz, 'shared-key')`)
|
|
require.NoError(t, err, "insert usage event")
|
|
|
|
filter := db.UsageFilter{
|
|
From: "2026-05-14",
|
|
To: "2026-05-14",
|
|
Timezone: "UTC",
|
|
Breakdowns: true,
|
|
}
|
|
result, err := store.GetDailyUsage(ctx, filter)
|
|
require.NoError(t, err, "GetDailyUsage")
|
|
assert.Equal(t, 450, result.Totals.InputTokens)
|
|
assert.Equal(t, 115, result.Totals.OutputTokens)
|
|
assert.Equal(t, 20, result.Totals.CacheReadTokens)
|
|
assert.Len(t, result.Daily[0].AgentBreakdowns, 2)
|
|
|
|
top, err := store.GetTopSessionsByCost(ctx, filter, 10)
|
|
require.NoError(t, err, "GetTopSessionsByCost")
|
|
require.Len(t, top, 3)
|
|
assert.Equal(t, "hermes-event", top[0].SessionID)
|
|
assert.Equal(t, 390, top[0].TotalTokens)
|
|
|
|
counts, err := store.GetUsageSessionCounts(ctx, filter)
|
|
require.NoError(t, err, "GetUsageSessionCounts")
|
|
assert.Equal(t, 3, counts.Total)
|
|
assert.Equal(t, 2, counts.ByAgent["hermes"])
|
|
assert.Equal(t, 2, counts.ByProject["proj-b"])
|
|
}
|
|
|
|
func TestPostgresUsagePreservesSessionSummaryUsageEventTokens(t *testing.T) {
|
|
_, store := prepareUsageSchema(t, "agentsview_usage_summary_event_test")
|
|
|
|
ctx := context.Background()
|
|
rawInput := db.MaxPlausibleTokens + 250_000
|
|
rawOutput := db.MaxPlausibleTokens + 500_000
|
|
_, 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 ('gpt-5.4', 1, 2, 0, 0, 'seed')`)
|
|
require.NoError(t, err, "insert pricing")
|
|
_, err = store.DB().ExecContext(ctx, `
|
|
INSERT INTO sessions (
|
|
id, machine, project, agent, started_at,
|
|
message_count, user_message_count,
|
|
total_output_tokens, peak_context_tokens,
|
|
has_total_output_tokens, has_peak_context_tokens
|
|
) VALUES (
|
|
'hermes-summary', 'test-machine', 'proj', 'hermes',
|
|
'2026-05-14T10:00:00Z'::timestamptz, 1, 1,
|
|
$1, $2, true, true
|
|
)`, rawOutput, rawInput)
|
|
require.NoError(t, err, "insert session")
|
|
_, err = store.DB().ExecContext(ctx, `
|
|
INSERT INTO usage_events (
|
|
session_id, message_ordinal, source, model, input_tokens,
|
|
output_tokens, occurred_at, dedup_key
|
|
) VALUES (
|
|
'hermes-summary', 0, 'session', 'gpt-5.4', $1, $2,
|
|
'2026-05-14T10:05:00Z'::timestamptz, 'session:hermes-summary'
|
|
)`, rawInput, rawOutput)
|
|
require.NoError(t, err, "insert usage event")
|
|
|
|
daily, err := store.GetDailyUsage(ctx, db.UsageFilter{
|
|
From: "2026-05-14",
|
|
To: "2026-05-14",
|
|
Timezone: "UTC",
|
|
})
|
|
require.NoError(t, err, "GetDailyUsage")
|
|
require.Len(t, daily.Daily, 1, "daily entries")
|
|
assert.Equal(t, rawInput, daily.Totals.InputTokens, "daily input")
|
|
assert.Equal(t, rawOutput, daily.Totals.OutputTokens, "daily output")
|
|
|
|
usage, err := store.GetSessionUsage(ctx, "hermes-summary", true)
|
|
require.NoError(t, err, "GetSessionUsage")
|
|
require.NotNil(t, usage, "session usage")
|
|
assert.Equal(t, rawOutput, usage.TotalOutputTokens)
|
|
assert.Equal(t, rawInput, usage.PeakContextTokens)
|
|
require.True(t, usage.HasCost, "HasCost")
|
|
wantCost := (float64(rawInput)*1.0 + float64(rawOutput)*2.0) / 1_000_000
|
|
assert.InDelta(t, wantCost, usage.CostUSD, 1e-9, "session cost")
|
|
require.Len(t, usage.Breakdown, 1, "Breakdown")
|
|
entry := usage.Breakdown[0]
|
|
assert.Equal(t, "session", entry.Source)
|
|
assert.Equal(t, "Step 1", entry.Label)
|
|
require.NotNil(t, entry.MessageOrdinal)
|
|
assert.Equal(t, 0, *entry.MessageOrdinal)
|
|
assert.Equal(t, rawInput, entry.InputTokens)
|
|
assert.Equal(t, rawOutput, entry.OutputTokens)
|
|
assert.True(t, entry.HasCost)
|
|
assert.InDelta(t, wantCost, entry.CostUSD, 1e-9, "breakdown cost")
|
|
}
|
|
|
|
func TestPostgresUsageCostsMessageReasoningTokens(t *testing.T) {
|
|
_, store := prepareUsageSchema(t, "agentsview_usage_message_reasoning_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 ('gpt-5.4', 1, 2, 0, 0, 'seed')`)
|
|
require.NoError(t, err, "insert pricing")
|
|
_, err = store.DB().ExecContext(ctx, `
|
|
INSERT INTO sessions (
|
|
id, machine, project, agent, started_at,
|
|
message_count, user_message_count
|
|
) VALUES (
|
|
'pg-message-reasoning', 'test-machine', 'proj', 'codex',
|
|
'2026-05-14T10:00: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 (
|
|
'pg-message-reasoning', 0, 'assistant', 'done',
|
|
'2026-05-14T10:30:00Z'::timestamptz, 4,
|
|
'gpt-5.4',
|
|
'{"input_tokens":1000,"output_tokens":0,"reasoning_tokens":3000000000}'
|
|
)`)
|
|
require.NoError(t, err, "insert message")
|
|
|
|
daily, err := store.GetDailyUsage(ctx, db.UsageFilter{
|
|
From: "2026-05-14",
|
|
To: "2026-05-14",
|
|
Timezone: "UTC",
|
|
})
|
|
require.NoError(t, err, "GetDailyUsage")
|
|
require.Len(t, daily.Daily, 1, "daily entries")
|
|
assert.Equal(t, 1000, daily.Totals.InputTokens)
|
|
assert.Zero(t, daily.Totals.OutputTokens)
|
|
assert.InDelta(t, 4.001, daily.Totals.TotalCost, 1e-12)
|
|
|
|
usage, err := store.GetSessionUsage(ctx, "pg-message-reasoning", true)
|
|
require.NoError(t, err, "GetSessionUsage")
|
|
require.NotNil(t, usage)
|
|
assert.True(t, usage.HasCost)
|
|
assert.InDelta(t, 4.001, usage.CostUSD, 1e-12)
|
|
}
|
|
|
|
func TestStoreGetDailyUsageSkipsCursorUsageForTerminationFilter(t *testing.T) {
|
|
_, store := prepareUsageSchema(t, "agentsview_usage_cursor_termination_test")
|
|
|
|
ctx := context.Background()
|
|
_, err := store.DB().ExecContext(ctx, `
|
|
INSERT INTO sessions (
|
|
id, machine, project, agent, started_at,
|
|
message_count, user_message_count, termination_status
|
|
) VALUES (
|
|
'clean-session', 'test-machine', 'proj', 'claude',
|
|
'2026-05-14T10:00:00Z'::timestamptz, 1, 1, 'clean'
|
|
)`)
|
|
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 (
|
|
'clean-session', 0, 'assistant', 'one',
|
|
'2026-05-14T10:30:00Z'::timestamptz, 3,
|
|
'claude-sonnet-4-20250514',
|
|
'{"input_tokens":100,"output_tokens":40}'
|
|
)`)
|
|
require.NoError(t, err, "insert message")
|
|
_, err = store.DB().ExecContext(ctx, `
|
|
INSERT INTO cursor_usage_events (
|
|
occurred_at, model, kind, input_tokens, output_tokens,
|
|
cache_read_tokens, charged_cents, cursor_token_fee,
|
|
user_id, user_email, dedup_key
|
|
) VALUES (
|
|
'2026-05-14T10:05:00Z'::timestamptz,
|
|
'claude-4.6-opus-high-thinking',
|
|
'USAGE_EVENT_KIND_USAGE_BASED',
|
|
1234, 567, 8901, 15.66, 3.32,
|
|
'152683922', 'member@example.com', 'cursor:termination'
|
|
)`)
|
|
require.NoError(t, err, "insert cursor usage")
|
|
|
|
result, err := store.GetDailyUsage(ctx, db.UsageFilter{
|
|
From: "2026-05-14",
|
|
To: "2026-05-14",
|
|
Timezone: "UTC",
|
|
Termination: "clean",
|
|
})
|
|
require.NoError(t, err, "GetDailyUsage clean termination")
|
|
require.Len(t, result.Daily, 1, "daily entries")
|
|
assert.Equal(t, 100, result.Totals.InputTokens, "InputTokens")
|
|
assert.Equal(t, 40, result.Totals.OutputTokens, "OutputTokens")
|
|
assert.Equal(t, 1, result.SessionCounts.Total, "SessionCounts.Total")
|
|
}
|
|
|
|
func TestPushSyncsModelPricingToPostgres(t *testing.T) {
|
|
pgURL := testPGURL(t)
|
|
cleanPGSchema(t, pgURL)
|
|
t.Cleanup(func() { cleanPGSchema(t, pgURL) })
|
|
|
|
local := testDB(t)
|
|
require.NoError(t, local.UpsertModelPricing([]db.ModelPricing{{
|
|
ModelPattern: "test-model-sync",
|
|
InputPerMTok: 1.5,
|
|
OutputPerMTok: 2.5,
|
|
CacheCreationPerMTok: 3.5,
|
|
CacheReadPerMTok: 0.5,
|
|
}}), "UpsertModelPricing")
|
|
|
|
ps, err := New(pgURL, "agentsview", local, "test-machine", true, SyncOptions{})
|
|
require.NoError(t, err, "New")
|
|
defer ps.Close()
|
|
|
|
_, err = ps.Push(context.Background(), false, nil)
|
|
require.NoError(t, err, "Push")
|
|
|
|
store, err := NewStore(pgURL, "agentsview", true)
|
|
require.NoError(t, err, "NewStore")
|
|
defer store.Close()
|
|
|
|
rows, err := store.DB().QueryContext(context.Background(), `
|
|
SELECT model_pattern, input_per_mtok, output_per_mtok,
|
|
cache_creation_per_mtok, cache_read_per_mtok
|
|
FROM model_pricing
|
|
WHERE model_pattern = 'test-model-sync'`)
|
|
require.NoError(t, err, "query pricing")
|
|
defer rows.Close()
|
|
|
|
require.True(t, rows.Next(), "expected synced pricing row")
|
|
var (
|
|
model string
|
|
input, output, cacheCreation, cacheRead float64
|
|
)
|
|
require.NoError(t, rows.Scan(
|
|
&model, &input, &output, &cacheCreation, &cacheRead,
|
|
), "scan pricing")
|
|
assert.Equal(t, "test-model-sync", model)
|
|
assert.Equal(t, 1.5, input)
|
|
assert.Equal(t, 2.5, output)
|
|
assert.Equal(t, 3.5, cacheCreation)
|
|
assert.Equal(t, 0.5, cacheRead)
|
|
}
|
|
|
|
func TestPushFallsBackToBuiltinPricingWhenLocalTableEmpty(t *testing.T) {
|
|
pgURL := testPGURL(t)
|
|
cleanPGSchema(t, pgURL)
|
|
t.Cleanup(func() { cleanPGSchema(t, pgURL) })
|
|
|
|
local := testDB(t)
|
|
ps, err := New(pgURL, "agentsview", local, "test-machine", true, SyncOptions{})
|
|
require.NoError(t, err, "New")
|
|
defer ps.Close()
|
|
|
|
_, err = ps.Push(context.Background(), false, nil)
|
|
require.NoError(t, err, "Push")
|
|
|
|
store, err := NewStore(pgURL, "agentsview", true)
|
|
require.NoError(t, err, "NewStore")
|
|
defer store.Close()
|
|
|
|
rows, err := store.DB().QueryContext(context.Background(), `
|
|
SELECT model_pattern
|
|
FROM model_pricing
|
|
ORDER BY model_pattern`)
|
|
require.NoError(t, err, "query pricing")
|
|
defer rows.Close()
|
|
|
|
var models []string
|
|
for rows.Next() {
|
|
var model string
|
|
require.NoError(t, rows.Scan(&model), "scan model")
|
|
models = append(models, model)
|
|
}
|
|
require.NoError(t, rows.Err(), "rows err")
|
|
joined := strings.Join(models, ",")
|
|
assert.Contains(t, joined, "claude-sonnet-4-20250514",
|
|
"fallback pricing not synced")
|
|
}
|
|
|
|
func TestStoreGetSessionUsage_CopilotAICreditsComputed(t *testing.T) {
|
|
_, store := prepareUsageSchema(t, "agentsview_copilot_credits_test")
|
|
|
|
ctx := context.Background()
|
|
_, err := store.DB().ExecContext(ctx, `
|
|
INSERT INTO sessions (
|
|
id, machine, project, agent, started_at,
|
|
message_count, user_message_count
|
|
) VALUES (
|
|
'copilot:s1', 'test-machine', 'proj', 'copilot',
|
|
'2026-03-12T10:00:00Z'::timestamptz, 1, 1
|
|
)`)
|
|
require.NoError(t, err, "insert session")
|
|
|
|
_, err = store.DB().ExecContext(ctx, `
|
|
INSERT INTO usage_events (
|
|
session_id, source, model, input_tokens, output_tokens, cost_usd, occurred_at
|
|
) VALUES (
|
|
'copilot:s1', 'api', 'gpt-4', 1000, 500, 0.10, '2026-03-12T10:00:00Z'::timestamptz
|
|
)`)
|
|
require.NoError(t, err, "insert usage event")
|
|
|
|
u, err := store.GetSessionUsage(ctx, "copilot:s1", true)
|
|
require.NoError(t, err, "GetSessionUsage")
|
|
require.NotNil(t, u, "usage is nil")
|
|
assert.True(t, u.HasCost, "HasCost")
|
|
assert.Equal(t, 0.10, u.CostUSD, "CostUSD")
|
|
assert.Equal(t, 10.0, u.AICredits, "AICredits")
|
|
}
|
|
|
|
func TestStoreGetSessionUsage_CopilotNoAICreditsUnpriced(t *testing.T) {
|
|
_, store := prepareUsageSchema(t, "agentsview_copilot_unpriced_test")
|
|
|
|
ctx := context.Background()
|
|
_, err := store.DB().ExecContext(ctx, `
|
|
INSERT INTO sessions (
|
|
id, machine, project, agent, started_at,
|
|
message_count, user_message_count
|
|
) VALUES (
|
|
'copilot:s2', 'test-machine', 'proj', 'copilot',
|
|
'2026-03-12T10:00:00Z'::timestamptz, 1, 1
|
|
)`)
|
|
require.NoError(t, err, "insert session")
|
|
|
|
_, err = store.DB().ExecContext(ctx, `
|
|
INSERT INTO usage_events (
|
|
session_id, source, model, input_tokens, output_tokens, occurred_at
|
|
) VALUES (
|
|
'copilot:s2', 'api', 'local-model', 1000, 500, '2026-03-12T10:00:00Z'::timestamptz
|
|
)`)
|
|
require.NoError(t, err, "insert usage event")
|
|
|
|
u, err := store.GetSessionUsage(ctx, "copilot:s2", true)
|
|
require.NoError(t, err, "GetSessionUsage")
|
|
require.NotNil(t, u, "usage is nil")
|
|
assert.False(t, u.HasCost, "HasCost should be false")
|
|
assert.Equal(t, 0.0, u.AICredits, "AICredits should be 0 when unpriced")
|
|
}
|