kenn-io--agentsview
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
3102 行
101 KiB
Go
3102 行
101 KiB
Go
//go:build !(windows && arm64)
|
|
|
|
package duckdb
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"database/sql/driver"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"go.kenn.io/agentsview/internal/config"
|
|
"go.kenn.io/agentsview/internal/db"
|
|
"go.kenn.io/agentsview/internal/export"
|
|
pricingpkg "go.kenn.io/agentsview/internal/pricing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
type sessionVersionProbeDriver struct{}
|
|
|
|
type sessionVersionProbeConn struct{}
|
|
|
|
type sessionVersionProbeRows struct {
|
|
columns []string
|
|
values [][]driver.Value
|
|
next int
|
|
}
|
|
|
|
var sessionVersionProbeRegisterOnce sync.Once
|
|
|
|
func newSessionVersionProbeStore(t *testing.T) *Store {
|
|
t.Helper()
|
|
sessionVersionProbeRegisterOnce.Do(func() {
|
|
sql.Register("agentsview_session_version_probe", sessionVersionProbeDriver{})
|
|
})
|
|
duck, err := sql.Open("agentsview_session_version_probe", t.Name())
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { require.NoError(t, duck.Close()) })
|
|
return &Store{
|
|
duck: duck,
|
|
connectionKind: duckDBQuackClientConnection,
|
|
}
|
|
}
|
|
|
|
func (sessionVersionProbeDriver) Open(string) (driver.Conn, error) {
|
|
return sessionVersionProbeConn{}, nil
|
|
}
|
|
|
|
func (sessionVersionProbeConn) Prepare(string) (driver.Stmt, error) {
|
|
return nil, errors.New("prepare not implemented")
|
|
}
|
|
|
|
func (sessionVersionProbeConn) Close() error { return nil }
|
|
|
|
func (sessionVersionProbeConn) Begin() (driver.Tx, error) {
|
|
return nil, errors.New("begin not implemented")
|
|
}
|
|
|
|
func (sessionVersionProbeConn) QueryContext(
|
|
_ context.Context, query string, args []driver.NamedValue,
|
|
) (driver.Rows, error) {
|
|
if !strings.Contains(query, quackAttachmentName+".query(?)") {
|
|
return nil, errors.New("direct session version query should not be used")
|
|
}
|
|
if len(args) != 1 {
|
|
return nil, fmt.Errorf("remote query got %d args, want 1", len(args))
|
|
}
|
|
sqlText, ok := args[0].Value.(string)
|
|
if !ok {
|
|
return nil, fmt.Errorf("remote query arg has type %T", args[0].Value)
|
|
}
|
|
if !strings.Contains(sqlText, "FROM sessions WHERE id") {
|
|
return nil, fmt.Errorf("unexpected remote query: %s", sqlText)
|
|
}
|
|
return &sessionVersionProbeRows{
|
|
columns: []string{
|
|
"message_count", "file_mtime", "file_hash", "updated_at",
|
|
},
|
|
values: [][]driver.Value{{
|
|
int64(7), int64(123), "hash",
|
|
"2026-01-10T00:00:00Z",
|
|
}},
|
|
}, nil
|
|
}
|
|
|
|
func (r *sessionVersionProbeRows) Columns() []string {
|
|
return r.columns
|
|
}
|
|
|
|
func (r *sessionVersionProbeRows) Close() error { return nil }
|
|
|
|
func (r *sessionVersionProbeRows) Next(dest []driver.Value) error {
|
|
if r.next >= len(r.values) {
|
|
return io.EOF
|
|
}
|
|
copy(dest, r.values[r.next])
|
|
r.next++
|
|
return nil
|
|
}
|
|
|
|
func TestDecodeCursorClearsLegacyTotal(t *testing.T) {
|
|
store := &Store{}
|
|
data, err := json.Marshal(db.SessionCursor{
|
|
EndedAt: "2026-01-10T00:00:00.000Z",
|
|
ID: "legacy-cursor",
|
|
Total: 42,
|
|
})
|
|
require.NoError(t, err)
|
|
raw := base64.RawURLEncoding.EncodeToString(data)
|
|
|
|
got, err := store.DecodeCursor(raw)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, "legacy-cursor", got.ID)
|
|
assert.Equal(t, 0, got.Total)
|
|
}
|
|
|
|
func TestQuackStoreGetSessionVersionUsesRemoteQuery(t *testing.T) {
|
|
store := newSessionVersionProbeStore(t)
|
|
|
|
count, marker, ok := store.GetSessionVersion("quoted ' session")
|
|
|
|
require.True(t, ok)
|
|
assert.Equal(t, 7, count)
|
|
assert.Equal(t,
|
|
db.SessionVersionMarker(
|
|
"123", "hash", "2026-01-10T00:00:00Z",
|
|
),
|
|
marker,
|
|
)
|
|
}
|
|
|
|
func TestStoreReadsSessionsMessagesAndMetadata(t *testing.T) {
|
|
ctx := context.Background()
|
|
store, fixture := newSyncedStore(t)
|
|
|
|
page, err := store.ListSessions(ctx, db.SessionFilter{Limit: 10})
|
|
require.NoError(t, err)
|
|
require.Len(t, page.Sessions, 2)
|
|
assert.Equal(t, 2, page.Total)
|
|
assert.Equal(t, fixture.betaID, page.Sessions[0].ID)
|
|
assert.Equal(t, fixture.alphaID, page.Sessions[1].ID)
|
|
|
|
sess, err := store.GetSession(ctx, fixture.alphaID)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, sess)
|
|
assert.Equal(t, "alpha", sess.Project)
|
|
assert.Equal(t, 2, sess.MessageCount)
|
|
|
|
msgs, err := store.GetAllMessages(ctx, fixture.alphaID)
|
|
require.NoError(t, err)
|
|
require.Len(t, msgs, 2)
|
|
assert.Equal(t, "alpha first", msgs[0].Content)
|
|
require.Len(t, msgs[1].ToolCalls, 1)
|
|
assert.Equal(t, "search", msgs[1].ToolCalls[0].ToolName)
|
|
require.Len(t, msgs[1].ToolCalls[0].ResultEvents, 1)
|
|
assert.Equal(t, "duck result", msgs[1].ToolCalls[0].ResultEvents[0].Content)
|
|
|
|
stats, err := store.GetStats(ctx, false, false)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 2, stats.SessionCount)
|
|
assert.Equal(t, 3, stats.MessageCount)
|
|
assert.Equal(t, 2, stats.ProjectCount)
|
|
assert.Equal(t, 1, stats.MachineCount)
|
|
require.NotNil(t, stats.EarliestSession)
|
|
|
|
projects, err := store.GetProjects(ctx, false, false)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []db.ProjectInfo{
|
|
{Name: "alpha", SessionCount: 1},
|
|
{Name: "beta", SessionCount: 1},
|
|
}, projects)
|
|
|
|
agents, err := store.GetAgents(ctx, false, false)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []db.AgentInfo{{Name: "claude", SessionCount: 2}}, agents)
|
|
|
|
machines, err := store.GetMachines(ctx, false, false)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"test-machine"}, machines)
|
|
}
|
|
|
|
func TestStoreGetStatsPreservesRootAndScopeFilters(t *testing.T) {
|
|
ctx := context.Background()
|
|
local := newLocalDB(t)
|
|
syncer := newInMemoryTestSync(t, local, SyncOptions{})
|
|
require.NoError(t, syncer.EnsureSchema(ctx))
|
|
duck := syncer.DB()
|
|
store := NewStoreFromDB(duck)
|
|
|
|
insertSession := func(
|
|
id, project, relationship, ts string,
|
|
messageCount, userMessageCount int,
|
|
automated bool,
|
|
) {
|
|
t.Helper()
|
|
_, err := duck.ExecContext(ctx, `
|
|
INSERT INTO sessions (
|
|
id, project, machine, agent, message_count,
|
|
user_message_count, relationship_type, is_automated,
|
|
started_at, created_at
|
|
) VALUES (
|
|
?, ?, 'stats-machine', 'claude', ?, ?, ?, ?,
|
|
CAST(? AS TIMESTAMP), CAST(? AS TIMESTAMP)
|
|
)`,
|
|
id, project, messageCount, userMessageCount, relationship,
|
|
automated, ts, ts,
|
|
)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
insertSession(
|
|
"stats-fork", "fork", "fork", "2025-12-26 00:00:00",
|
|
1, 2, false,
|
|
)
|
|
insertSession(
|
|
"stats-subagent", "child", "subagent", "2025-12-27 00:00:00",
|
|
1, 2, false,
|
|
)
|
|
insertSession(
|
|
"stats-empty", "empty", "root", "2025-12-28 00:00:00",
|
|
0, 2, false,
|
|
)
|
|
insertSession(
|
|
"stats-deleted", "deleted", "root", "2025-12-29 00:00:00",
|
|
1, 2, false,
|
|
)
|
|
insertSession(
|
|
"stats-one-shot", "beta", "root", "2025-12-30 00:00:00",
|
|
1, 1, false,
|
|
)
|
|
insertSession(
|
|
"stats-automated", "bot", "root", "2025-12-31 00:00:00",
|
|
1, 1, true,
|
|
)
|
|
insertSession(
|
|
"stats-human", "alpha", "root", "2026-01-01 00:00:00",
|
|
2, 2, false,
|
|
)
|
|
_, err := duck.ExecContext(ctx,
|
|
`UPDATE sessions SET deleted_at = CAST(? AS TIMESTAMP) WHERE id = ?`,
|
|
"2026-01-02 00:00:00", "stats-deleted",
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
assertStats := func(
|
|
name string,
|
|
excludeOneShot, excludeAutomated bool,
|
|
wantSessions, wantMessages, wantProjects int,
|
|
wantEarliest string,
|
|
) {
|
|
t.Helper()
|
|
stats, err := store.GetStats(ctx, excludeOneShot, excludeAutomated)
|
|
require.NoError(t, err, name)
|
|
assert.Equal(t, wantSessions, stats.SessionCount, name)
|
|
assert.Equal(t, wantMessages, stats.MessageCount, name)
|
|
assert.Equal(t, wantProjects, stats.ProjectCount, name)
|
|
assert.Equal(t, 1, stats.MachineCount, name)
|
|
require.NotNil(t, stats.EarliestSession, name)
|
|
assert.Equal(t, wantEarliest, *stats.EarliestSession, name)
|
|
}
|
|
|
|
assertStats(
|
|
"include all root sessions", false, false, 3, 4, 3,
|
|
"2025-12-30T00:00:00Z",
|
|
)
|
|
assertStats(
|
|
"exclude one-shot keeps automated", true, false, 2, 3, 2,
|
|
"2025-12-31T00:00:00Z",
|
|
)
|
|
assertStats(
|
|
"exclude automated keeps human one-shot", false, true, 2, 3, 2,
|
|
"2025-12-30T00:00:00Z",
|
|
)
|
|
assertStats(
|
|
"exclude one-shot and automated", true, true, 1, 2, 1,
|
|
"2026-01-01T00:00:00Z",
|
|
)
|
|
|
|
labels, err := store.GetActiveProjectLabels(ctx)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{
|
|
"alpha", "beta", "bot", "child", "empty", "fork",
|
|
}, labels)
|
|
}
|
|
|
|
func TestStoreMessageIDJoinsAreSessionScoped(t *testing.T) {
|
|
ctx := context.Background()
|
|
store, fixture := newSyncedStore(t)
|
|
insertOtherMachineDuckSession(t, store.duck)
|
|
|
|
msgs, err := store.GetAllMessages(ctx, fixture.alphaID)
|
|
require.NoError(t, err)
|
|
require.Len(t, msgs, 2)
|
|
assert.Empty(t, msgs[0].ToolCalls)
|
|
require.Len(t, msgs[1].ToolCalls, 1)
|
|
assert.Equal(t, "search", msgs[1].ToolCalls[0].ToolName)
|
|
|
|
content, err := store.SearchContent(ctx, db.ContentSearchFilter{
|
|
Pattern: "wrong-session-tool",
|
|
Sources: []string{"tool_input"},
|
|
IncludeOneShot: true,
|
|
Limit: 10,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, content.Matches, 1)
|
|
assert.Equal(t, "other-session", content.Matches[0].SessionID)
|
|
assert.Equal(t, 0, content.Matches[0].Ordinal)
|
|
|
|
pins, err := store.ListPinnedMessages(ctx, "", "alpha")
|
|
require.NoError(t, err)
|
|
foundOtherPin := false
|
|
for _, pin := range pins {
|
|
if pin.SessionID == "other-session" {
|
|
foundOtherPin = true
|
|
require.NotNil(t, pin.Content)
|
|
assert.Equal(t, "from other machine", *pin.Content)
|
|
}
|
|
}
|
|
assert.True(t, foundOtherPin)
|
|
}
|
|
|
|
func TestStoreSearchesMessagesContentAndSecrets(t *testing.T) {
|
|
ctx := context.Background()
|
|
store, fixture := newSyncedStore(t)
|
|
|
|
search, err := store.Search(ctx, db.SearchFilter{Query: "secret token", Limit: 10})
|
|
require.NoError(t, err)
|
|
require.Len(t, search.Results, 1)
|
|
assert.Equal(t, fixture.alphaID, search.Results[0].SessionID)
|
|
assert.Equal(t, 1, search.Results[0].Ordinal)
|
|
|
|
ordinals, err := store.SearchSession(ctx, fixture.alphaID, "duck result")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []int{1}, ordinals)
|
|
|
|
content, err := store.SearchContent(ctx, db.ContentSearchFilter{
|
|
Pattern: "duck result",
|
|
Sources: []string{"tool_result"},
|
|
IncludeOneShot: true,
|
|
Limit: 10,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, content.Matches, 1)
|
|
assert.Equal(t, "tool_result", content.Matches[0].Location)
|
|
assert.Equal(t, fixture.alphaID, content.Matches[0].SessionID)
|
|
|
|
findings, err := store.ListSecretFindings(ctx, db.SecretFindingFilter{
|
|
Project: "alpha",
|
|
Limit: 10,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, findings.Findings, 1)
|
|
finding := findings.Findings[0]
|
|
assert.Equal(t, "test_secret", finding.RuleName)
|
|
assert.Equal(t, "alpha", finding.Project)
|
|
|
|
source, ok, err := store.SecretFindingSource(ctx, finding.SecretFinding)
|
|
require.NoError(t, err)
|
|
require.True(t, ok)
|
|
assert.Equal(t, "secret token sk-duckdb", source)
|
|
}
|
|
|
|
func TestSearchContentFTSSingleTermFallback(t *testing.T) {
|
|
ctx := context.Background()
|
|
store, fixture := newSyncedStore(t)
|
|
|
|
got, err := store.SearchContent(ctx, db.ContentSearchFilter{
|
|
Pattern: "alpha",
|
|
Mode: "fts",
|
|
Sources: []string{"messages"},
|
|
IncludeOneShot: true,
|
|
Limit: 10,
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, got.Matches)
|
|
assert.Equal(t, fixture.alphaID, got.Matches[0].SessionID)
|
|
assert.Equal(t, "message", got.Matches[0].Location)
|
|
}
|
|
|
|
func TestSearchContentFTSMatchesNonContiguousTerms(t *testing.T) {
|
|
ctx := context.Background()
|
|
local := newLocalDB(t)
|
|
body := strings.Repeat("prefix ", 30) + "the quick brown fox jumps"
|
|
_, err := local.WriteSessionBatchAtomic([]db.SessionBatchWrite{
|
|
{
|
|
Session: syncSession(
|
|
"duck-fts-both", "alpha", "first",
|
|
"2026-03-22T10:00:00.000Z", 1,
|
|
),
|
|
Messages: []db.Message{syncMessage(
|
|
"duck-fts-both", 0, "user",
|
|
body,
|
|
"2026-03-22T10:00:00.000Z",
|
|
)},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
},
|
|
{
|
|
Session: syncSession(
|
|
"duck-fts-one", "alpha", "first",
|
|
"2026-03-22T11:00:00.000Z", 1,
|
|
),
|
|
Messages: []db.Message{syncMessage(
|
|
"duck-fts-one", 0, "user",
|
|
"the quick answer only",
|
|
"2026-03-22T11:00:00.000Z",
|
|
)},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
syncer := newInMemoryTestSync(t, local, SyncOptions{})
|
|
_, err = syncer.Push(ctx, true, nil)
|
|
require.NoError(t, err)
|
|
store := NewStoreFromDB(syncer.DB())
|
|
|
|
got, err := store.SearchContent(ctx, db.ContentSearchFilter{
|
|
Pattern: "quick fox",
|
|
Mode: "fts",
|
|
Sources: []string{"messages"},
|
|
IncludeOneShot: true,
|
|
Limit: 10,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, got.Matches, 1)
|
|
assert.Equal(t, "duck-fts-both", got.Matches[0].SessionID)
|
|
assert.Contains(t, got.Matches[0].Snippet, "quick")
|
|
assert.Contains(t, got.Matches[0].Snippet, "fox")
|
|
}
|
|
|
|
// TestSearchContentOrdinalRangeSelfRange pins the ordinal_range contract on
|
|
// DuckDB content search: the field is always present and derived from the
|
|
// conversation-unit rules, never a zero-valued [0, 0] at a nonzero anchor
|
|
// ordinal. This fixture's assistant anchor is a single-member run bounded by
|
|
// the user opener, so the derived range equals the self-range. Substring and
|
|
// regex modes cover the two scan paths (scanDuckContentRows and the regex
|
|
// candidate loop).
|
|
func TestSearchContentOrdinalRangeSelfRange(t *testing.T) {
|
|
ctx := context.Background()
|
|
local := newLocalDB(t)
|
|
_, err := local.WriteSessionBatchAtomic([]db.SessionBatchWrite{{
|
|
Session: syncSession(
|
|
"duck-range", "alpha", "first",
|
|
"2026-03-22T10:00:00.000Z", 2,
|
|
),
|
|
Messages: []db.Message{
|
|
syncMessage("duck-range", 0, "user",
|
|
"an unrelated opener", "2026-03-22T10:00:00.000Z"),
|
|
syncMessage("duck-range", 1, "assistant",
|
|
"the rangeneedle reply", "2026-03-22T10:00:01.000Z"),
|
|
},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
}})
|
|
require.NoError(t, err)
|
|
|
|
syncer := newInMemoryTestSync(t, local, SyncOptions{})
|
|
_, err = syncer.Push(ctx, true, nil)
|
|
require.NoError(t, err)
|
|
store := NewStoreFromDB(syncer.DB())
|
|
|
|
for _, mode := range []string{"substring", "regex"} {
|
|
t.Run(mode, func(t *testing.T) {
|
|
got, err := store.SearchContent(ctx, db.ContentSearchFilter{
|
|
Pattern: "rangeneedle",
|
|
Mode: mode,
|
|
Sources: []string{"messages"},
|
|
IncludeOneShot: true,
|
|
Limit: 10,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, got.Matches, 1)
|
|
m := got.Matches[0]
|
|
require.Equal(t, 1, m.Ordinal, "anchor ordinal")
|
|
assert.Equal(t, [2]int{1, 1}, m.OrdinalRange,
|
|
"ordinal_range must be the derived single-member run, not [0, 0]")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSearchContentInvalidModeReturnsInputError(t *testing.T) {
|
|
ctx := context.Background()
|
|
store, _ := newSyncedStore(t)
|
|
|
|
_, err := store.SearchContent(ctx, db.ContentSearchFilter{
|
|
Pattern: "alpha",
|
|
Mode: "bad-mode",
|
|
Sources: []string{"messages"},
|
|
IncludeOneShot: true,
|
|
Limit: 10,
|
|
})
|
|
require.Error(t, err)
|
|
var inputErr *db.SearchInputError
|
|
assert.True(t, errors.As(err, &inputErr),
|
|
"expected *SearchInputError, got %T: %v", err, err)
|
|
}
|
|
|
|
func TestSearchContentRedactsSecretsUnlessRevealed(t *testing.T) {
|
|
ctx := context.Background()
|
|
local := newLocalDB(t)
|
|
sessionID := "duck-secret-content"
|
|
secretBody := "prefix AKIA" + "7QHWN2DKR4FYPLJM needle suffix"
|
|
_, err := local.WriteSessionBatchAtomic([]db.SessionBatchWrite{{
|
|
Session: syncSession(sessionID, "alpha", "secret first", "2026-01-16T00:00:00.000Z", 1),
|
|
Messages: []db.Message{syncMessage(sessionID, 0, "user", secretBody, "2026-01-16T00:00:00.000Z")},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
}})
|
|
require.NoError(t, err)
|
|
|
|
syncer := newInMemoryTestSync(t, local, SyncOptions{})
|
|
_, err = syncer.Push(ctx, true, nil)
|
|
require.NoError(t, err)
|
|
store := NewStoreFromDB(syncer.DB())
|
|
|
|
redacted, err := store.SearchContent(ctx, db.ContentSearchFilter{
|
|
Pattern: "needle",
|
|
Sources: []string{"messages"},
|
|
IncludeOneShot: true,
|
|
Limit: 10,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, redacted.Matches, 1)
|
|
assert.NotContains(t, redacted.Matches[0].Snippet, "AKIA"+"7QHWN2DKR4FYPLJM")
|
|
|
|
revealed, err := store.SearchContent(ctx, db.ContentSearchFilter{
|
|
Pattern: "needle",
|
|
Sources: []string{"messages"},
|
|
IncludeOneShot: true,
|
|
RevealSecrets: true,
|
|
Limit: 10,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, revealed.Matches, 1)
|
|
assert.Contains(t, revealed.Matches[0].Snippet, "AKIA"+"7QHWN2DKR4FYPLJM")
|
|
}
|
|
|
|
func TestSearchGroupsMessagesAndIncludesNameMatches(t *testing.T) {
|
|
ctx := context.Background()
|
|
local := newLocalDB(t)
|
|
|
|
nameSession := syncSession("duck-search-name", "alpha", "plain first", "2026-01-15T00:00:00.000Z", 1)
|
|
sessionName := "needle session name"
|
|
nameSession.SessionName = &sessionName
|
|
_, err := local.WriteSessionBatchAtomic([]db.SessionBatchWrite{
|
|
{
|
|
Session: syncSession("duck-search-content", "alpha", "content first", "2026-01-14T00:00:00.000Z", 2),
|
|
Messages: []db.Message{
|
|
syncMessage("duck-search-content", 0, "user", "prefix needle hit", "2026-01-14T00:00:00.000Z"),
|
|
syncMessage("duck-search-content", 1, "assistant", "needle second hit", "2026-01-14T00:01:00.000Z"),
|
|
},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
},
|
|
{
|
|
Session: nameSession,
|
|
Messages: []db.Message{syncMessage("duck-search-name", 0, "user", "plain body", "2026-01-15T00:00:00.000Z")},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
syncer := newInMemoryTestSync(t, local, SyncOptions{})
|
|
_, err = syncer.Push(ctx, true, nil)
|
|
require.NoError(t, err)
|
|
store := NewStoreFromDB(syncer.DB())
|
|
|
|
got, err := store.Search(ctx, db.SearchFilter{Query: "needle", Limit: 10})
|
|
require.NoError(t, err)
|
|
require.Len(t, got.Results, 2)
|
|
assert.Equal(t, "duck-search-content", got.Results[0].SessionID)
|
|
assert.Equal(t, 1, got.Results[0].Ordinal)
|
|
assert.Equal(t, "duck-search-name", got.Results[1].SessionID)
|
|
assert.Equal(t, -1, got.Results[1].Ordinal)
|
|
assert.Equal(t, "needle session name", got.Results[1].Snippet)
|
|
|
|
quotedContent, err := store.Search(ctx, db.SearchFilter{Query: `"needle second"`, Limit: 10})
|
|
require.NoError(t, err)
|
|
require.Len(t, quotedContent.Results, 1)
|
|
assert.Equal(t, "duck-search-content", quotedContent.Results[0].SessionID)
|
|
assert.Equal(t, 1, quotedContent.Results[0].Ordinal)
|
|
|
|
quotedName, err := store.Search(ctx, db.SearchFilter{Query: `"needle session"`, Limit: 10})
|
|
require.NoError(t, err)
|
|
require.Len(t, quotedName.Results, 1)
|
|
assert.Equal(t, "duck-search-name", quotedName.Results[0].SessionID)
|
|
assert.Equal(t, -1, quotedName.Results[0].Ordinal)
|
|
|
|
renamed := "needle override rename"
|
|
require.NoError(t, local.RenameSession("duck-search-name", &renamed))
|
|
_, err = syncer.Push(ctx, false, nil)
|
|
require.NoError(t, err)
|
|
overridden, err := store.Search(ctx, db.SearchFilter{Query: "override", Limit: 10})
|
|
require.NoError(t, err)
|
|
require.Len(t, overridden.Results, 1)
|
|
assert.Equal(t, "duck-search-name", overridden.Results[0].SessionID)
|
|
assert.Equal(t, -1, overridden.Results[0].Ordinal)
|
|
assert.Equal(t, "needle override rename", overridden.Results[0].Snippet)
|
|
}
|
|
|
|
// TestSearchOperatorTokenNoError mirrors the SQLite FTS 500 regression on the
|
|
// DuckDB/ILIKE backend: a single token containing operator characters (hyphen,
|
|
// colon), prepared the way the HTTP handler does, must match content and not
|
|
// error. ILIKE has no FTS-operator hazard, but this pins backend parity.
|
|
func TestSearchOperatorTokenNoError(t *testing.T) {
|
|
ctx := context.Background()
|
|
local := newLocalDB(t)
|
|
sessionID := "duck-optok-001"
|
|
_, err := local.WriteSessionBatchAtomic([]db.SessionBatchWrite{{
|
|
Session: syncSession(sessionID, "alpha", "first msg text", "2026-03-20T10:00:00.000Z", 2),
|
|
Messages: []db.Message{
|
|
syncMessage(sessionID, 0, "user", "hit error-401 from the api", "2026-03-20T10:00:00.000Z"),
|
|
syncMessage(sessionID, 1, "assistant", "returned status:500 to client", "2026-03-20T10:00:01.000Z"),
|
|
},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
}})
|
|
require.NoError(t, err)
|
|
|
|
syncer := newInMemoryTestSync(t, local, SyncOptions{})
|
|
_, err = syncer.Push(ctx, true, nil)
|
|
require.NoError(t, err)
|
|
store := NewStoreFromDB(syncer.DB())
|
|
|
|
for _, raw := range []string{"error-401", "status:500"} {
|
|
page, err := store.Search(ctx, db.SearchFilter{
|
|
Query: db.PrepareFTSQuery(raw), Limit: 10,
|
|
})
|
|
require.NoError(t, err, "Search(%q)", raw)
|
|
require.Len(t, page.Results, 1, "results for %q", raw)
|
|
assert.Equal(t, sessionID, page.Results[0].SessionID, "session for %q", raw)
|
|
}
|
|
}
|
|
|
|
// TestSearchMultiTermAND verifies that a multi-term query matches a session only
|
|
// when every term appears in its content (AND), matching SQLite FTS5's implicit
|
|
// AND so the same user query behaves identically across backends. Before the
|
|
// fix, DuckDB stripped only the outer quote pair from PrepareFTSQuery's
|
|
// `"fix" "bug"` output and matched the literal substring `fix" "bug`, which
|
|
// found nothing.
|
|
func TestSearchMultiTermAND(t *testing.T) {
|
|
ctx := context.Background()
|
|
local := newLocalDB(t)
|
|
_, err := local.WriteSessionBatchAtomic([]db.SessionBatchWrite{
|
|
{
|
|
Session: syncSession("duck-andboth-001", "alpha", "first msg text", "2026-03-21T10:00:00.000Z", 1),
|
|
Messages: []db.Message{syncMessage("duck-andboth-001", 0, "user", "duckfixterm and duckbugterm both here", "2026-03-21T10:00:00.000Z")},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
},
|
|
{
|
|
Session: syncSession("duck-andone-001", "alpha", "first msg text", "2026-03-21T11:00:00.000Z", 1),
|
|
Messages: []db.Message{syncMessage("duck-andone-001", 0, "user", "only duckfixterm present", "2026-03-21T11:00:00.000Z")},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
syncer := newInMemoryTestSync(t, local, SyncOptions{})
|
|
_, err = syncer.Push(ctx, true, nil)
|
|
require.NoError(t, err)
|
|
store := NewStoreFromDB(syncer.DB())
|
|
|
|
page, err := store.Search(ctx, db.SearchFilter{
|
|
Query: db.PrepareFTSQuery("duckfixterm duckbugterm"), Limit: 10,
|
|
})
|
|
require.NoError(t, err, "Search")
|
|
require.Len(t, page.Results, 1, "only the session containing both terms")
|
|
assert.Equal(t, "duck-andboth-001", page.Results[0].SessionID, "session")
|
|
}
|
|
|
|
func TestStoreCurationMethods(t *testing.T) {
|
|
ctx := context.Background()
|
|
store, fixture := newSyncedStore(t)
|
|
|
|
starred, err := store.ListStarredSessionIDs(ctx)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{fixture.alphaID}, starred)
|
|
|
|
ok, err := store.StarSession(fixture.betaID)
|
|
require.ErrorIs(t, err, db.ErrReadOnly)
|
|
assert.False(t, ok)
|
|
require.ErrorIs(t, store.BulkStarSessions([]string{fixture.betaID}), db.ErrReadOnly)
|
|
require.ErrorIs(t, store.UnstarSession(fixture.alphaID), db.ErrReadOnly)
|
|
starred, err = store.ListStarredSessionIDs(ctx)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{fixture.alphaID}, starred)
|
|
|
|
msgs, err := store.GetAllMessages(ctx, fixture.betaID)
|
|
require.NoError(t, err)
|
|
require.Len(t, msgs, 1)
|
|
note := "duck pin"
|
|
pinID, err := store.PinMessage(fixture.betaID, msgs[0].ID, ¬e)
|
|
require.ErrorIs(t, err, db.ErrReadOnly)
|
|
assert.Zero(t, pinID)
|
|
|
|
require.ErrorIs(t, store.UnpinMessage(fixture.alphaID, msgs[0].ID), db.ErrReadOnly)
|
|
pins, err := store.ListPinnedMessages(ctx, fixture.alphaID, "")
|
|
require.NoError(t, err)
|
|
require.Len(t, pins, 1)
|
|
assert.Equal(t, "pin alpha", *pins[0].Note)
|
|
}
|
|
|
|
func TestStoreAnalyticsUsageAndTrends(t *testing.T) {
|
|
ctx := context.Background()
|
|
store, fixture := newSyncedStore(t)
|
|
filter := db.AnalyticsFilter{
|
|
From: "2026-01-01",
|
|
To: "2026-01-31",
|
|
}
|
|
|
|
summary, err := store.GetAnalyticsSummary(ctx, filter)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 2, summary.TotalSessions)
|
|
assert.Equal(t, 3, summary.TotalMessages)
|
|
assert.Equal(t, 2, summary.ActiveProjects)
|
|
|
|
activity, err := store.GetAnalyticsActivity(ctx, filter, "day")
|
|
require.NoError(t, err)
|
|
assert.NotEmpty(t, activity.Series)
|
|
|
|
heatmap, err := store.GetAnalyticsHeatmap(ctx, filter, "messages")
|
|
require.NoError(t, err)
|
|
require.Len(t, heatmap.Entries, 31)
|
|
|
|
projects, err := store.GetAnalyticsProjects(ctx, filter)
|
|
require.NoError(t, err)
|
|
require.Len(t, projects.Projects, 2)
|
|
|
|
hours, err := store.GetAnalyticsHourOfWeek(ctx, filter)
|
|
require.NoError(t, err)
|
|
assert.Len(t, hours.Cells, 168)
|
|
|
|
shape, err := store.GetAnalyticsSessionShape(ctx, filter)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 2, shape.Count)
|
|
assert.Equal(t, 1, distributionCount(shape.AutonomyDistribution, "1-2"))
|
|
assert.Equal(t, 1, distributionCount(shape.AutonomyDistribution, "<0.5"))
|
|
|
|
tools, err := store.GetAnalyticsTools(ctx, filter)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 1, tools.TotalCalls)
|
|
|
|
velocity, err := store.GetAnalyticsVelocity(ctx, filter)
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, velocity)
|
|
|
|
top, err := store.GetAnalyticsTopSessions(ctx, filter, "messages")
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, top.Sessions)
|
|
assert.Equal(t, fixture.alphaID, top.Sessions[0].ID)
|
|
|
|
signals, err := store.GetAnalyticsSignals(ctx, filter)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 2, signals.UnscoredSessions)
|
|
|
|
trendTerms, err := db.ParseTrendTerms([]string{"alpha"})
|
|
require.NoError(t, err)
|
|
trends, err := store.GetTrendsTerms(ctx, filter, trendTerms, "week")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 1, trends.Series[0].Total)
|
|
|
|
usageFilter := db.UsageFilter{
|
|
From: "2026-01-01",
|
|
To: "2026-01-31",
|
|
}
|
|
usage, err := store.GetDailyUsage(ctx, usageFilter)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 13, usage.Totals.InputTokens)
|
|
assert.Equal(t, 11, usage.Totals.OutputTokens)
|
|
assert.InDelta(t, 0.000204, usage.Totals.TotalCost, 0.000001)
|
|
|
|
topCost, err := store.GetTopSessionsByCost(ctx, usageFilter, 10)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, topCost)
|
|
assert.Equal(t, fixture.alphaID, topCost[0].SessionID)
|
|
|
|
counts, err := store.GetUsageSessionCounts(ctx, usageFilter)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 2, counts.Total)
|
|
assert.Equal(t, 1, counts.ByProject["alpha"])
|
|
|
|
sessionUsage, err := store.GetSessionUsage(ctx, fixture.alphaID, true)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, sessionUsage)
|
|
assert.True(t, sessionUsage.HasCost)
|
|
assert.Equal(t, []string{"claude-test"}, sessionUsage.Models)
|
|
}
|
|
|
|
func TestLoadPricingUsesDBRowsAsEffectiveTableAndOverlaysOverrides(t *testing.T) {
|
|
ctx := context.Background()
|
|
conn := openTestDuckDB(t)
|
|
require.NoError(t, EnsureSchema(ctx, conn))
|
|
store := NewStoreFromDB(conn)
|
|
store.SetCustomPricing(map[string]config.CustomModelRate{
|
|
"custom-model": {
|
|
Input: 9, Output: 10, CacheCreation: 11, CacheRead: 12,
|
|
},
|
|
})
|
|
|
|
_, err := conn.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-6', 30, 150, 37.5, 3.0, '2026-06-08T12:00:00Z'),
|
|
('_fallback_version', 999, 999, 999, 999, '')`)
|
|
require.NoError(t, err)
|
|
|
|
got, err := store.loadPricing(ctx)
|
|
require.NoError(t, err)
|
|
|
|
assert.NotContains(t, got, "gpt-5.5")
|
|
assert.Equal(t, duckRates{
|
|
input: 30, output: 150, cacheCreation: 37.5, cacheRead: 3.0,
|
|
updatedAt: ptrTime(t, "2026-06-08T12:00:00Z"),
|
|
source: export.PricingRowSourceFetched,
|
|
}, got["claude-sonnet-4-6"])
|
|
assert.NotContains(t, got, "_fallback_version")
|
|
assert.Equal(t, duckRates{
|
|
input: 9, output: 10, cacheCreation: 11, cacheRead: 12,
|
|
source: export.PricingRowSourceCustom,
|
|
}, got["custom-model"])
|
|
}
|
|
|
|
func TestProjectIdentityMapLegacyFallbackUsesFilePath(t *testing.T) {
|
|
ctx := context.Background()
|
|
conn := openTestDuckDB(t)
|
|
require.NoError(t, EnsureSchema(ctx, conn))
|
|
store := NewStoreFromDB(conn)
|
|
|
|
_, err := conn.ExecContext(ctx, `
|
|
INSERT INTO source_archives (source_archive_id, source_archive_salt)
|
|
VALUES (?, ?)`, "legacy-test-archive", "legacy-test-salt")
|
|
require.NoError(t, err)
|
|
|
|
_, err = conn.ExecContext(ctx, `
|
|
INSERT INTO sessions (id, project, machine, agent, cwd, file_path)
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
"file-path-identity", "file-project", "laptop", "codex", "",
|
|
"/fixtures/duck-file-project/session.jsonl",
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
got, err := store.BuildProjectIdentityMap(ctx, []string{"file-project"})
|
|
require.NoError(t, err)
|
|
require.Equal(t, export.ProjectResolutionUnknown,
|
|
got["file-project"].Resolution)
|
|
assert.Nil(t, got["file-project"].Identity)
|
|
}
|
|
|
|
func TestProjectIdentityMapLegacySessionsUseDistinctFallbackKeys(t *testing.T) {
|
|
ctx := context.Background()
|
|
conn := openTestDuckDB(t)
|
|
require.NoError(t, EnsureSchema(ctx, conn))
|
|
_, err := conn.ExecContext(ctx, `
|
|
INSERT INTO sessions (id, project, machine, agent)
|
|
VALUES
|
|
('legacy-alpha', 'alpha', 'host', 'codex'),
|
|
('legacy-beta', 'beta', 'host', 'codex')`)
|
|
require.NoError(t, err)
|
|
|
|
store := NewStoreFromDB(conn)
|
|
first, err := store.BuildProjectIdentityMap(ctx, []string{"alpha", "beta"})
|
|
require.NoError(t, err)
|
|
second, err := store.BuildProjectIdentityMap(ctx, []string{"alpha", "beta"})
|
|
require.NoError(t, err)
|
|
|
|
assert.NotEmpty(t, first["alpha"].ProjectKey)
|
|
assert.NotEmpty(t, first["beta"].ProjectKey)
|
|
assert.NotEqual(t, first["alpha"].ProjectKey, first["beta"].ProjectKey)
|
|
assert.Equal(t, first["alpha"].ProjectKey, second["alpha"].ProjectKey)
|
|
assert.Equal(t, first["beta"].ProjectKey, second["beta"].ProjectKey)
|
|
assert.Len(t, export.ProjectMapForWire(first), 2)
|
|
}
|
|
|
|
func TestProjectIdentityObservationRoundTripsRepositoryContext(t *testing.T) {
|
|
ctx := context.Background()
|
|
conn := openTestDuckDB(t)
|
|
require.NoError(t, EnsureSchema(ctx, conn))
|
|
observedAt := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)
|
|
_, err := conn.ExecContext(ctx, `
|
|
INSERT INTO source_project_identity_observations (
|
|
source_archive_id, source_archive_salt,
|
|
project, machine, root_path, git_remote, git_remote_name,
|
|
repository_path, worktree_name, worktree_root_path,
|
|
worktree_relationship, checkout_state, git_branch,
|
|
remote_resolution, remote_candidate_count, observed_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
"archive-source", "archive-salt",
|
|
"app", "host", "/private/app-worktree", "", "",
|
|
"/private/app/.git", "feature", "/private/app-worktree",
|
|
export.WorktreeLinked, export.CheckoutDetached, "",
|
|
export.ProjectResolutionAmbiguous, 2, observedAt,
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
got, err := NewStoreFromDB(conn).ListProjectIdentityObservations(ctx, []string{"app"})
|
|
require.NoError(t, err)
|
|
require.Len(t, got, 1)
|
|
assert.Equal(t, "archive-source", got[0].SourceArchiveID)
|
|
assert.Equal(t, "archive-salt", got[0].SourceArchiveSalt)
|
|
assert.Equal(t, "/private/app/.git", got[0].RepositoryPath)
|
|
assert.Equal(t, export.WorktreeLinked, got[0].WorktreeRelationship)
|
|
assert.Equal(t, export.CheckoutDetached, got[0].CheckoutState)
|
|
assert.Equal(t, export.ProjectResolutionAmbiguous, got[0].RemoteResolution)
|
|
assert.Equal(t, 2, got[0].RemoteCandidateCount)
|
|
}
|
|
|
|
func TestProjectIdentityObservationsAggregateSourceArchives(t *testing.T) {
|
|
ctx := context.Background()
|
|
conn := openTestDuckDB(t)
|
|
require.NoError(t, EnsureSchema(ctx, conn))
|
|
for i, archiveID := range []string{"archive-a", "archive-b"} {
|
|
_, err := conn.ExecContext(ctx, `
|
|
INSERT INTO source_archives (source_archive_id, source_archive_salt)
|
|
VALUES (?, ?)`, archiveID, archiveID+"-salt")
|
|
require.NoError(t, err)
|
|
_, err = conn.ExecContext(ctx, `
|
|
INSERT INTO source_project_identity_observations (
|
|
source_archive_id, source_archive_salt, project, machine,
|
|
root_path, git_remote, observed_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
archiveID, archiveID+"-salt", "app", "host", "/repo/app",
|
|
fmt.Sprintf("https://github.com/acme/app-%d.git", i), time.Now().UTC(),
|
|
)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
store := NewStoreFromDB(conn)
|
|
all, err := store.ListProjectIdentityObservations(ctx, []string{"app"})
|
|
require.NoError(t, err)
|
|
require.Len(t, all, 2)
|
|
aggregate, err := store.BuildProjectIdentityMap(ctx, []string{"app", "missing"})
|
|
require.NoError(t, err)
|
|
assert.NotEmpty(t, aggregate["app"].ProjectKey)
|
|
assert.NotEmpty(t, aggregate["missing"].ProjectKey)
|
|
assert.Contains(t, export.ProjectMapForWire(aggregate), aggregate["app"].ProjectKey)
|
|
assert.Contains(t, export.ProjectMapForWire(aggregate), aggregate["missing"].ProjectKey)
|
|
|
|
}
|
|
|
|
func TestSourceArchiveScopeRejectsSaltMismatch(t *testing.T) {
|
|
ctx := context.Background()
|
|
conn := openTestDuckDB(t)
|
|
require.NoError(t, EnsureSchema(ctx, conn))
|
|
exec := func(query string, args ...any) error {
|
|
_, err := conn.ExecContext(ctx, query, args...)
|
|
return err
|
|
}
|
|
|
|
queryRow := func(query string, args ...any) *sql.Row {
|
|
return conn.QueryRowContext(ctx, query, args...)
|
|
}
|
|
require.NoError(t, upsertSourceArchiveScope(
|
|
exec, queryRow, "archive-a", "salt-a"))
|
|
err := upsertSourceArchiveScope(exec, queryRow, "archive-a", "salt-b")
|
|
require.ErrorContains(t, err, "archive salt mismatch")
|
|
}
|
|
|
|
func TestLoadPricingUsesFallbackWhenEffectiveTableEmpty(t *testing.T) {
|
|
ctx := context.Background()
|
|
conn := openTestDuckDB(t)
|
|
require.NoError(t, EnsureSchema(ctx, conn))
|
|
store := NewStoreFromDB(conn)
|
|
|
|
got, err := store.loadPricing(ctx)
|
|
require.NoError(t, err)
|
|
|
|
fallback := pricingByPattern(t, pricingpkg.FallbackPricing(), "gpt-5.5")
|
|
require.Contains(t, got, "gpt-5.5")
|
|
assert.Equal(t, fallback.InputPerMTok, got["gpt-5.5"].input)
|
|
assert.Equal(t, fallback.OutputPerMTok, got["gpt-5.5"].output)
|
|
assert.Equal(t, export.PricingRowSourceEmbedded, got["gpt-5.5"].source)
|
|
}
|
|
|
|
func TestLoadPricingRetainsCustomOverrideSource(t *testing.T) {
|
|
ctx := context.Background()
|
|
conn := openTestDuckDB(t)
|
|
require.NoError(t, EnsureSchema(ctx, conn))
|
|
store := NewStoreFromDB(conn)
|
|
fallback := pricingByPattern(t, pricingpkg.FallbackPricing(), "gpt-5.5")
|
|
store.SetCustomPricing(map[string]config.CustomModelRate{
|
|
"gpt-5.5": {
|
|
Input: fallback.InputPerMTok,
|
|
Output: fallback.OutputPerMTok,
|
|
CacheCreation: fallback.CacheCreationPerMTok,
|
|
CacheRead: fallback.CacheReadPerMTok,
|
|
},
|
|
})
|
|
|
|
got, err := store.loadPricing(ctx)
|
|
require.NoError(t, err)
|
|
block, err := export.NewPricingResolver(duckPricingRows(got)).BuildBlock()
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, "custom+embedded", block.Source)
|
|
assert.Equal(t, 1, block.CustomOverrideCount)
|
|
}
|
|
|
|
func pricingByPattern(t *testing.T, prices []pricingpkg.ModelPricing, pattern string) pricingpkg.ModelPricing {
|
|
t.Helper()
|
|
for _, p := range prices {
|
|
if p.ModelPattern == pattern {
|
|
return p
|
|
}
|
|
}
|
|
t.Fatalf("missing fallback pricing for %s", pattern)
|
|
return pricingpkg.ModelPricing{}
|
|
}
|
|
|
|
func ptrTime(t *testing.T, value string) *time.Time {
|
|
t.Helper()
|
|
parsed, err := time.Parse(time.RFC3339Nano, value)
|
|
require.NoError(t, err)
|
|
utc := parsed.UTC()
|
|
return &utc
|
|
}
|
|
|
|
func TestAnalyticsTopSessionsFiltersMetricEligibility(t *testing.T) {
|
|
ctx := context.Background()
|
|
local := newLocalDB(t)
|
|
writes := []db.SessionBatchWrite{
|
|
{
|
|
Session: syncSession(
|
|
"duck-top-valid-output", "alpha", "valid output",
|
|
"2026-01-20T00:00:00.000Z", 1,
|
|
),
|
|
Messages: []db.Message{syncMessage(
|
|
"duck-top-valid-output", 0, "assistant", "valid output",
|
|
"2026-01-20T00:00:00.000Z",
|
|
)},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
},
|
|
{
|
|
Session: syncSession(
|
|
"duck-top-untracked-output", "alpha", "untracked output",
|
|
"2026-01-20T01:00:00.000Z", 1,
|
|
),
|
|
Messages: []db.Message{syncMessage(
|
|
"duck-top-untracked-output", 0, "assistant", "untracked output",
|
|
"2026-01-20T01:00:00.000Z",
|
|
)},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
},
|
|
{
|
|
Session: syncSession(
|
|
"duck-top-valid-duration", "alpha", "valid duration",
|
|
"2026-01-20T02:00:00.000Z", 1,
|
|
),
|
|
Messages: []db.Message{syncMessage(
|
|
"duck-top-valid-duration", 0, "assistant", "valid duration",
|
|
"2026-01-20T02:00:00.000Z",
|
|
)},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
},
|
|
{
|
|
Session: syncSession(
|
|
"duck-top-missing-duration", "alpha", "missing duration",
|
|
"2026-01-20T03:00:00.000Z", 1,
|
|
),
|
|
Messages: []db.Message{syncMessage(
|
|
"duck-top-missing-duration", 0, "assistant", "missing duration",
|
|
"2026-01-20T03:00:00.000Z",
|
|
)},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
},
|
|
}
|
|
_, err := local.WriteSessionBatchAtomic(writes)
|
|
require.NoError(t, err)
|
|
syncer := newInMemoryTestSync(t, local, SyncOptions{})
|
|
_, err = syncer.Push(ctx, true, nil)
|
|
require.NoError(t, err)
|
|
|
|
_, err = syncer.DB().ExecContext(ctx, `
|
|
UPDATE sessions
|
|
SET total_output_tokens = 25, has_total_output_tokens = TRUE
|
|
WHERE id = 'duck-top-valid-output'`)
|
|
require.NoError(t, err)
|
|
_, err = syncer.DB().ExecContext(ctx, `
|
|
UPDATE sessions
|
|
SET total_output_tokens = 999, has_total_output_tokens = FALSE
|
|
WHERE id = 'duck-top-untracked-output'`)
|
|
require.NoError(t, err)
|
|
_, err = syncer.DB().ExecContext(ctx, `
|
|
UPDATE sessions
|
|
SET started_at = '2026-01-20T02:00:00.000Z',
|
|
ended_at = '2026-01-20T02:30:00.000Z'
|
|
WHERE id = 'duck-top-valid-duration'`)
|
|
require.NoError(t, err)
|
|
_, err = syncer.DB().ExecContext(ctx, `
|
|
UPDATE sessions
|
|
SET started_at = NULL, ended_at = NULL
|
|
WHERE id = 'duck-top-missing-duration'`)
|
|
require.NoError(t, err)
|
|
|
|
store := NewStoreFromDB(syncer.DB())
|
|
filter := db.AnalyticsFilter{From: "2026-01-20", To: "2026-01-20"}
|
|
output, err := store.GetAnalyticsTopSessions(ctx, filter, "output_tokens")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "output_tokens", output.Metric)
|
|
require.NotEmpty(t, output.Sessions)
|
|
assert.NotEqual(t, "duck-top-untracked-output", output.Sessions[0].ID)
|
|
for _, session := range output.Sessions {
|
|
assert.NotEqual(t, "duck-top-untracked-output", session.ID)
|
|
}
|
|
|
|
duration, err := store.GetAnalyticsTopSessions(ctx, filter, "duration")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "duration", duration.Metric)
|
|
require.NotEmpty(t, duration.Sessions)
|
|
seenValidDuration := false
|
|
for _, session := range duration.Sessions {
|
|
assert.NotEqual(t, "duck-top-missing-duration", session.ID)
|
|
if session.ID == "duck-top-valid-duration" {
|
|
seenValidDuration = true
|
|
assert.Equal(t, 30.0, session.DurationMin)
|
|
}
|
|
}
|
|
assert.True(t, seenValidDuration, "valid duration session was filtered out")
|
|
|
|
unknown, err := store.GetAnalyticsTopSessions(ctx, filter, "not-a-metric")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "messages", unknown.Metric)
|
|
}
|
|
|
|
func TestAnalyticsTopSessionsDurationUsesActiveDuration(t *testing.T) {
|
|
ctx := context.Background()
|
|
local := newLocalDB(t)
|
|
wallSession := syncSession(
|
|
"duck-wall-dominant", "alpha", "wall session",
|
|
"2026-01-20T09:00:00.000Z", 3,
|
|
)
|
|
wallEndedAt := "2026-01-20T11:00:00.000Z"
|
|
wallSession.EndedAt = &wallEndedAt
|
|
activeSession := syncSession(
|
|
"duck-actively-working", "alpha", "active session",
|
|
"2026-01-20T09:30:00.000Z", 3,
|
|
)
|
|
activeEndedAt := "2026-01-20T09:50:00.000Z"
|
|
activeSession.EndedAt = &activeEndedAt
|
|
writes := []db.SessionBatchWrite{
|
|
{
|
|
Session: wallSession,
|
|
Messages: []db.Message{
|
|
syncMessage(
|
|
"duck-wall-dominant", 0, "user", "wall start",
|
|
"2026-01-20T09:00:00.000Z",
|
|
),
|
|
syncMessage(
|
|
"duck-wall-dominant", 1, "assistant", "wall tool",
|
|
"2026-01-20T10:59:00.000Z",
|
|
db.ToolCall{
|
|
ToolName: "Read",
|
|
Category: "Read",
|
|
ToolUseID: "duck-wall-tool",
|
|
InputJSON: `{"file_path":"README.md"}`,
|
|
},
|
|
),
|
|
syncMessage(
|
|
"duck-wall-dominant", 2, "user", "wall finish",
|
|
"2026-01-20T11:00:00.000Z",
|
|
),
|
|
},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
},
|
|
{
|
|
Session: activeSession,
|
|
Messages: []db.Message{
|
|
syncMessage(
|
|
"duck-actively-working", 0, "user", "active start",
|
|
"2026-01-20T09:30:00.000Z",
|
|
),
|
|
syncMessage(
|
|
"duck-actively-working", 1, "assistant", "active tool",
|
|
"2026-01-20T09:35:00.000Z",
|
|
db.ToolCall{
|
|
ToolName: "Edit",
|
|
Category: "Write",
|
|
ToolUseID: "duck-active-tool",
|
|
InputJSON: `{"file_path":"main.go"}`,
|
|
},
|
|
),
|
|
syncMessage(
|
|
"duck-actively-working", 2, "user", "active finish",
|
|
"2026-01-20T09:50:00.000Z",
|
|
),
|
|
},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
},
|
|
}
|
|
_, err := local.WriteSessionBatchAtomic(writes)
|
|
require.NoError(t, err)
|
|
|
|
syncer := newInMemoryTestSync(t, local, SyncOptions{})
|
|
_, err = syncer.Push(ctx, true, nil)
|
|
require.NoError(t, err)
|
|
|
|
store := NewStoreFromDB(syncer.DB())
|
|
filter := db.AnalyticsFilter{From: "2026-01-20", To: "2026-01-20"}
|
|
resp, err := store.GetAnalyticsTopSessions(ctx, filter, "duration")
|
|
require.NoError(t, err)
|
|
require.Len(t, resp.Sessions, 2)
|
|
|
|
assert.Equal(t, "duck-actively-working", resp.Sessions[0].ID)
|
|
assert.Equal(t, 20.0, resp.Sessions[0].DurationMin)
|
|
// 5 min user->asst gap + a 15 min gap capped at the 5 min idle
|
|
// cap = 10.
|
|
assert.Equal(t, 10.0, resp.Sessions[0].ActiveDurationMin)
|
|
assert.Equal(t, "duck-wall-dominant", resp.Sessions[1].ID)
|
|
assert.Equal(t, 120.0, resp.Sessions[1].DurationMin)
|
|
// 119 min idle gap capped to 5 + a 1 min gap = 6.
|
|
assert.Equal(t, 6.0, resp.Sessions[1].ActiveDurationMin)
|
|
}
|
|
|
|
func TestAnalyticsProjectsPopulateDailyTrendAndSortByMessages(t *testing.T) {
|
|
ctx := context.Background()
|
|
local := newLocalDB(t)
|
|
writes := []db.SessionBatchWrite{
|
|
{
|
|
Session: syncSession("duck-project-alpha", "alpha", "alpha first", "2026-01-20T00:00:00.000Z", 5),
|
|
Messages: []db.Message{
|
|
syncMessage("duck-project-alpha", 0, "user", "alpha 0", "2026-01-20T00:00:00.000Z"),
|
|
syncMessage("duck-project-alpha", 1, "assistant", "alpha 1", "2026-01-20T00:01:00.000Z"),
|
|
syncMessage("duck-project-alpha", 2, "user", "alpha 2", "2026-01-20T00:02:00.000Z"),
|
|
syncMessage("duck-project-alpha", 3, "assistant", "alpha 3", "2026-01-20T00:03:00.000Z"),
|
|
syncMessage("duck-project-alpha", 4, "user", "alpha 4", "2026-01-20T00:04:00.000Z"),
|
|
},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
},
|
|
{
|
|
Session: syncSession("duck-project-zeta-a", "zeta", "zeta a", "2026-01-20T01:00:00.000Z", 1),
|
|
Messages: []db.Message{syncMessage("duck-project-zeta-a", 0, "user", "zeta a", "2026-01-20T01:00:00.000Z")},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
},
|
|
{
|
|
Session: syncSession("duck-project-zeta-b", "zeta", "zeta b", "2026-01-20T02:00:00.000Z", 1),
|
|
Messages: []db.Message{syncMessage("duck-project-zeta-b", 0, "user", "zeta b", "2026-01-20T02:00:00.000Z")},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
},
|
|
}
|
|
_, err := local.WriteSessionBatchAtomic(writes)
|
|
require.NoError(t, err)
|
|
|
|
syncer := newInMemoryTestSync(t, local, SyncOptions{})
|
|
_, err = syncer.Push(ctx, true, nil)
|
|
require.NoError(t, err)
|
|
store := NewStoreFromDB(syncer.DB())
|
|
|
|
got, err := store.GetAnalyticsProjects(ctx, db.AnalyticsFilter{
|
|
From: "2026-01-01",
|
|
To: "2026-01-31",
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, got.Projects, 2)
|
|
assert.Equal(t, "alpha", got.Projects[0].Name)
|
|
assert.Equal(t, 5, got.Projects[0].Messages)
|
|
assert.Equal(t, 5.0, got.Projects[0].DailyTrend)
|
|
assert.Equal(t, "zeta", got.Projects[1].Name)
|
|
assert.Equal(t, 2.0, got.Projects[1].DailyTrend)
|
|
}
|
|
|
|
func TestAnalyticsVelocityUsesMessageCyclesAndBreakdowns(t *testing.T) {
|
|
ctx := context.Background()
|
|
local := newLocalDB(t)
|
|
sessionID := "duck-velocity-cycles"
|
|
call := db.ToolCall{
|
|
ToolName: "search",
|
|
Category: "search",
|
|
ToolUseID: "duck-velocity-tool",
|
|
InputJSON: `{"query":"velocity"}`,
|
|
}
|
|
_, err := local.WriteSessionBatchAtomic([]db.SessionBatchWrite{{
|
|
Session: syncSession(sessionID, "alpha", "velocity first", "2026-01-22T00:00:00.000Z", 4),
|
|
Messages: []db.Message{
|
|
syncMessage(sessionID, 0, "user", "u1", "2026-01-22T00:00:00.000Z"),
|
|
syncMessage(sessionID, 1, "assistant", "assistant-one", "2026-01-22T00:00:30.000Z", call),
|
|
syncMessage(sessionID, 2, "user", "u2", "2026-01-22T00:01:30.000Z"),
|
|
syncMessage(sessionID, 3, "assistant", "assistant-two", "2026-01-22T00:02:00.000Z"),
|
|
},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
}})
|
|
require.NoError(t, err)
|
|
|
|
syncer := newInMemoryTestSync(t, local, SyncOptions{})
|
|
_, err = syncer.Push(ctx, true, nil)
|
|
require.NoError(t, err)
|
|
store := NewStoreFromDB(syncer.DB())
|
|
|
|
got, err := store.GetAnalyticsVelocity(ctx, db.AnalyticsFilter{
|
|
From: "2026-01-01",
|
|
To: "2026-01-31",
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 30.0, got.Overall.TurnCycleSec.P50)
|
|
assert.Equal(t, 30.0, got.Overall.FirstResponseSec.P50)
|
|
assert.Equal(t, 2.0, got.Overall.MsgsPerActiveMin)
|
|
assert.Equal(t, 13.0, got.Overall.CharsPerActiveMin)
|
|
assert.Equal(t, 0.5, got.Overall.ToolCallsPerActiveMin)
|
|
require.Len(t, got.ByAgent, 1)
|
|
assert.Equal(t, "claude", got.ByAgent[0].Label)
|
|
assert.Equal(t, 1, got.ByAgent[0].Sessions)
|
|
require.Len(t, got.ByComplexity, 1)
|
|
assert.Equal(t, "1-15", got.ByComplexity[0].Label)
|
|
}
|
|
|
|
func TestAnalyticsVelocitySingleMessageSessionsReturnArrays(t *testing.T) {
|
|
ctx := context.Background()
|
|
local := newLocalDB(t)
|
|
sessionID := "duck-velocity-single"
|
|
_, err := local.WriteSessionBatchAtomic([]db.SessionBatchWrite{{
|
|
Session: syncSession(sessionID, "alpha", "single", "2026-01-22T01:00:00.000Z", 1),
|
|
Messages: []db.Message{syncMessage(sessionID, 0, "user", "single", "2026-01-22T01:00:00.000Z")},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
}})
|
|
require.NoError(t, err)
|
|
|
|
syncer := newInMemoryTestSync(t, local, SyncOptions{})
|
|
_, err = syncer.Push(ctx, true, nil)
|
|
require.NoError(t, err)
|
|
store := NewStoreFromDB(syncer.DB())
|
|
|
|
got, err := store.GetAnalyticsVelocity(ctx, db.AnalyticsFilter{
|
|
From: "2026-01-01",
|
|
To: "2026-01-31",
|
|
})
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, got.ByAgent)
|
|
assert.Empty(t, got.ByAgent)
|
|
assert.NotNil(t, got.ByComplexity)
|
|
assert.Empty(t, got.ByComplexity)
|
|
}
|
|
|
|
func TestGetSessionTimingPopulatesSharedTimingPayload(t *testing.T) {
|
|
ctx := context.Background()
|
|
local := newLocalDB(t)
|
|
sessionID := "duck-timing"
|
|
startedAt := "2026-01-20T00:00:00.000Z"
|
|
endedAt := "2026-01-20T00:03:00.000Z"
|
|
sess := syncSession(sessionID, "alpha", "timing first", startedAt, 2)
|
|
sess.EndedAt = &endedAt
|
|
_, err := local.WriteSessionBatchAtomic([]db.SessionBatchWrite{{
|
|
Session: sess,
|
|
Messages: []db.Message{
|
|
syncMessage(sessionID, 0, "user", "timing first", startedAt),
|
|
syncMessage(sessionID, 1, "assistant", "tool response", "2026-01-20T00:01:00.000Z",
|
|
db.ToolCall{
|
|
ToolName: "Read",
|
|
Category: "Read",
|
|
ToolUseID: "tool-timing",
|
|
InputJSON: `{"file_path":"README.md"}`,
|
|
}),
|
|
},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
}})
|
|
require.NoError(t, err)
|
|
|
|
syncer := newInMemoryTestSync(t, local, SyncOptions{})
|
|
_, err = syncer.Push(ctx, true, nil)
|
|
require.NoError(t, err)
|
|
store := NewStoreFromDB(syncer.DB())
|
|
|
|
timing, err := store.GetSessionTiming(ctx, sessionID)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, timing)
|
|
assert.Equal(t, sessionID, timing.SessionID)
|
|
assert.Equal(t, int64(180000), timing.TotalDurationMs)
|
|
assert.Equal(t, 1, timing.TurnCount)
|
|
assert.Equal(t, 1, timing.ToolCallCount)
|
|
assert.False(t, timing.Running)
|
|
require.Len(t, timing.Turns, 1)
|
|
assert.Equal(t, 1, timing.Turns[0].Ordinal)
|
|
require.NotNil(t, timing.Turns[0].DurationMs)
|
|
assert.Equal(t, int64(120000), *timing.Turns[0].DurationMs)
|
|
require.Len(t, timing.Turns[0].Calls, 1)
|
|
require.NotNil(t, timing.Turns[0].Calls[0].DurationMs)
|
|
assert.Equal(t, int64(120000), *timing.Turns[0].Calls[0].DurationMs)
|
|
}
|
|
|
|
func TestGetAllMessagesDoesNotTruncateAtDefaultLimit(t *testing.T) {
|
|
ctx := context.Background()
|
|
local := newLocalDB(t)
|
|
sessionID := "duck-large-session"
|
|
const messageCount = db.MaxMessageLimit + 5
|
|
|
|
messages := make([]db.Message, 0, messageCount)
|
|
for i := range messageCount {
|
|
messages = append(messages, syncMessage(
|
|
sessionID, i, "assistant",
|
|
fmt.Sprintf("message-%04d", i),
|
|
"2026-01-12T00:00:00.000Z",
|
|
))
|
|
}
|
|
_, err := local.WriteSessionBatchAtomic([]db.SessionBatchWrite{{
|
|
Session: syncSession(sessionID, "large", "large first", "2026-01-12T00:00:00.000Z", messageCount),
|
|
Messages: messages,
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
}})
|
|
require.NoError(t, err)
|
|
|
|
syncer := newInMemoryTestSync(t, local, SyncOptions{})
|
|
_, err = syncer.Push(ctx, true, nil)
|
|
require.NoError(t, err)
|
|
store := NewStoreFromDB(syncer.DB())
|
|
|
|
got, err := store.GetAllMessages(ctx, sessionID)
|
|
require.NoError(t, err)
|
|
require.Len(t, got, messageCount)
|
|
assert.Equal(t, "message-1004", got[messageCount-1].Content)
|
|
}
|
|
|
|
func TestSearchContentRegexDoesNotUseLiteralLikePrefilter(t *testing.T) {
|
|
ctx := context.Background()
|
|
store, _ := newSyncedStore(t)
|
|
|
|
got, err := store.SearchContent(ctx, db.ContentSearchFilter{
|
|
Pattern: `duck\s+result`,
|
|
Mode: "regex",
|
|
Sources: []string{"tool_result"},
|
|
IncludeOneShot: true,
|
|
Limit: 10,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, got.Matches, 1)
|
|
assert.Equal(t, "duck result", got.Matches[0].Snippet)
|
|
}
|
|
|
|
func TestSearchContentRegexPaginatesAfterGlobalOrdering(t *testing.T) {
|
|
ctx := context.Background()
|
|
store, _ := newSyncedStore(t)
|
|
|
|
first, err := store.SearchContent(ctx, db.ContentSearchFilter{
|
|
Pattern: `alpha|duck\s+result`,
|
|
Mode: "regex",
|
|
Sources: []string{"tool_result", "messages"},
|
|
IncludeOneShot: true,
|
|
Limit: 1,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, first.Matches, 1)
|
|
assert.Equal(t, "message", first.Matches[0].Location)
|
|
assert.Equal(t, "alpha first", first.Matches[0].Snippet)
|
|
assert.Equal(t, 1, first.NextCursor)
|
|
|
|
second, err := store.SearchContent(ctx, db.ContentSearchFilter{
|
|
Pattern: `alpha|duck\s+result`,
|
|
Mode: "regex",
|
|
Sources: []string{"tool_result", "messages"},
|
|
IncludeOneShot: true,
|
|
Limit: 1,
|
|
Cursor: first.NextCursor,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, second.Matches, 1)
|
|
assert.Equal(t, "tool_result", second.Matches[0].Location)
|
|
assert.Equal(t, "duck result", second.Matches[0].Snippet)
|
|
}
|
|
|
|
func TestSearchContentRegexOrdersBySessionRecency(t *testing.T) {
|
|
ctx := context.Background()
|
|
local := newLocalDB(t)
|
|
_, err := local.WriteSessionBatchAtomic([]db.SessionBatchWrite{
|
|
{
|
|
Session: syncSession("a-old-regex", "alpha", "old", "2026-01-11T00:00:00Z", 1),
|
|
Messages: []db.Message{syncMessage("a-old-regex", 0, "user", "target word old", "2026-01-11T00:00:00Z")},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
},
|
|
{
|
|
Session: syncSession("z-new-regex", "alpha", "new", "2026-01-11T00:00:00.500Z", 1),
|
|
Messages: []db.Message{syncMessage("z-new-regex", 0, "user", "target word new", "2026-01-11T00:00:00.500Z")},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
syncer := newInMemoryTestSync(t, local, SyncOptions{})
|
|
_, err = syncer.Push(ctx, true, nil)
|
|
require.NoError(t, err)
|
|
store := NewStoreFromDB(syncer.DB())
|
|
|
|
got, err := store.SearchContent(ctx, db.ContentSearchFilter{
|
|
Pattern: `target\s+word`,
|
|
Mode: "regex",
|
|
Sources: []string{"messages"},
|
|
IncludeOneShot: true,
|
|
Limit: 10,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, got.Matches, 2)
|
|
assert.Equal(t, "z-new-regex", got.Matches[0].SessionID)
|
|
assert.Equal(t, "a-old-regex", got.Matches[1].SessionID)
|
|
}
|
|
|
|
func TestSearchContentGitBranchFilter(t *testing.T) {
|
|
ctx := context.Background()
|
|
local := newLocalDB(t)
|
|
alphaMain := syncSession("branch-alpha-main", "alpha", "main session", "2026-01-11T00:00:00Z", 1)
|
|
alphaMain.GitBranch = "main"
|
|
alphaFeature := syncSession("branch-alpha-feature", "alpha", "feature session", "2026-01-11T00:01:00Z", 1)
|
|
alphaFeature.GitBranch = "feature"
|
|
betaMain := syncSession("branch-beta-main", "beta", "beta session", "2026-01-11T00:02:00Z", 1)
|
|
betaMain.GitBranch = "main"
|
|
_, err := local.WriteSessionBatchAtomic([]db.SessionBatchWrite{
|
|
{
|
|
Session: alphaMain,
|
|
Messages: []db.Message{syncMessage(alphaMain.ID, 0, "user", "BRANCHNEEDLE alpha main", "2026-01-11T00:00:00Z")},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
},
|
|
{
|
|
Session: alphaFeature,
|
|
Messages: []db.Message{syncMessage(alphaFeature.ID, 0, "user", "BRANCHNEEDLE alpha feature", "2026-01-11T00:01:00Z")},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
},
|
|
{
|
|
Session: betaMain,
|
|
Messages: []db.Message{syncMessage(betaMain.ID, 0, "user", "BRANCHNEEDLE beta main", "2026-01-11T00:02:00Z")},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
syncer := newInMemoryTestSync(t, local, SyncOptions{})
|
|
_, err = syncer.Push(ctx, true, nil)
|
|
require.NoError(t, err)
|
|
store := NewStoreFromDB(syncer.DB())
|
|
|
|
got, err := store.SearchContent(ctx, db.ContentSearchFilter{
|
|
Pattern: "BRANCHNEEDLE",
|
|
Mode: "substring",
|
|
Sources: []string{"messages"},
|
|
GitBranch: db.EncodeBranchFilterToken("alpha", "main"),
|
|
IncludeOneShot: true,
|
|
Limit: 10,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, got.Matches, 1)
|
|
assert.Equal(t, alphaMain.ID, got.Matches[0].SessionID)
|
|
}
|
|
|
|
func TestSearchContentSubstringPaginatesAfterGlobalOrdering(t *testing.T) {
|
|
ctx := context.Background()
|
|
store, _ := newSyncedStore(t)
|
|
|
|
first, err := store.SearchContent(ctx, db.ContentSearchFilter{
|
|
Pattern: "duck",
|
|
Sources: []string{"tool_result", "messages"},
|
|
IncludeOneShot: true,
|
|
Limit: 1,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, first.Matches, 1)
|
|
assert.Equal(t, "message", first.Matches[0].Location)
|
|
assert.Equal(t, "secret token sk-duckdb", first.Matches[0].Snippet)
|
|
assert.Equal(t, 1, first.NextCursor)
|
|
|
|
second, err := store.SearchContent(ctx, db.ContentSearchFilter{
|
|
Pattern: "duck",
|
|
Sources: []string{"tool_result", "messages"},
|
|
IncludeOneShot: true,
|
|
Limit: 1,
|
|
Cursor: first.NextCursor,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, second.Matches, 1)
|
|
assert.Equal(t, "tool_result", second.Matches[0].Location)
|
|
assert.Equal(t, "duck result", second.Matches[0].Snippet)
|
|
}
|
|
|
|
func TestSearchContentToolResultEmptyToolUseIDNotSuppressedByEvents(t *testing.T) {
|
|
ctx := context.Background()
|
|
local := newLocalDB(t)
|
|
sessionID := "duck-empty-tool-use"
|
|
call := db.ToolCall{
|
|
ToolName: "legacy",
|
|
Category: "other",
|
|
ResultContent: "legacy needle result",
|
|
ResultContentLength: len("legacy needle result"),
|
|
ResultEvents: []db.ToolResultEvent{{
|
|
Source: "tool",
|
|
Status: "complete",
|
|
Content: "event result without the target",
|
|
ContentLength: len("event result without the target"),
|
|
Timestamp: "2026-01-19T00:02:00.000Z",
|
|
EventIndex: 0,
|
|
}},
|
|
}
|
|
_, err := local.WriteSessionBatchAtomic([]db.SessionBatchWrite{{
|
|
Session: syncSession(sessionID, "alpha", "empty tool use", "2026-01-19T00:00:00.000Z", 1),
|
|
Messages: []db.Message{
|
|
syncMessage(sessionID, 0, "assistant", "called tool", "2026-01-19T00:01:00.000Z", call),
|
|
},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
}})
|
|
require.NoError(t, err)
|
|
syncer := newInMemoryTestSync(t, local, SyncOptions{})
|
|
_, err = syncer.Push(ctx, true, nil)
|
|
require.NoError(t, err)
|
|
store := NewStoreFromDB(syncer.DB())
|
|
|
|
for _, mode := range []string{"substring", "regex"} {
|
|
t.Run(mode, func(t *testing.T) {
|
|
filter := db.ContentSearchFilter{
|
|
Pattern: "legacy needle",
|
|
Sources: []string{"tool_result"},
|
|
IncludeOneShot: true,
|
|
Limit: 10,
|
|
}
|
|
if mode == "regex" {
|
|
filter.Mode = "regex"
|
|
filter.Pattern = `legacy\s+needle`
|
|
}
|
|
got, err := store.SearchContent(ctx, filter)
|
|
require.NoError(t, err)
|
|
require.Len(t, got.Matches, 1)
|
|
assert.Equal(t, "tool_result", got.Matches[0].Location)
|
|
assert.Contains(t, got.Matches[0].Snippet, "legacy needle")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSearchContentLegacyToolResultsUseCallIndexTieBreaker(t *testing.T) {
|
|
ctx := context.Background()
|
|
local := newLocalDB(t)
|
|
sessionID := "duck-legacy-tool-result-order"
|
|
first := "legacy needle first"
|
|
second := "legacy needle second"
|
|
_, err := local.WriteSessionBatchAtomic([]db.SessionBatchWrite{{
|
|
Session: syncSession(
|
|
sessionID, "alpha", "tool result order",
|
|
"2026-01-19T00:00:00.000Z", 1,
|
|
),
|
|
Messages: []db.Message{
|
|
syncMessage(
|
|
sessionID, 0, "assistant", "called tools",
|
|
"2026-01-19T00:01:00.000Z",
|
|
db.ToolCall{
|
|
ToolName: "legacy",
|
|
Category: "other",
|
|
ResultContent: first,
|
|
ResultContentLength: len(first),
|
|
},
|
|
db.ToolCall{
|
|
ToolName: "legacy",
|
|
Category: "other",
|
|
ResultContent: second,
|
|
ResultContentLength: len(second),
|
|
},
|
|
),
|
|
},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
}})
|
|
require.NoError(t, err)
|
|
syncer := newInMemoryTestSync(t, local, SyncOptions{})
|
|
_, err = syncer.Push(ctx, true, nil)
|
|
require.NoError(t, err)
|
|
store := NewStoreFromDB(syncer.DB())
|
|
|
|
for _, mode := range []string{"substring", "regex"} {
|
|
t.Run(mode, func(t *testing.T) {
|
|
filter := db.ContentSearchFilter{
|
|
Pattern: "legacy needle",
|
|
Sources: []string{"tool_result"},
|
|
IncludeOneShot: true,
|
|
Limit: 1,
|
|
}
|
|
if mode == "regex" {
|
|
filter.Mode = "regex"
|
|
filter.Pattern = `legacy\s+needle`
|
|
}
|
|
page, err := store.SearchContent(ctx, filter)
|
|
require.NoError(t, err)
|
|
require.Len(t, page.Matches, 1)
|
|
assert.Contains(t, page.Matches[0].Snippet, first)
|
|
require.NotZero(t, page.NextCursor)
|
|
|
|
filter.Cursor = page.NextCursor
|
|
page, err = store.SearchContent(ctx, filter)
|
|
require.NoError(t, err)
|
|
require.Len(t, page.Matches, 1)
|
|
assert.Contains(t, page.Matches[0].Snippet, second)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSearchContentToolResultEventsUseCallIndexTieBreaker(t *testing.T) {
|
|
ctx := context.Background()
|
|
local := newLocalDB(t)
|
|
sessionID := "duck-tool-result-event-order"
|
|
first := "event needle first"
|
|
second := "event needle second"
|
|
_, err := local.WriteSessionBatchAtomic([]db.SessionBatchWrite{{
|
|
Session: syncSession(
|
|
sessionID, "alpha", "tool result event order",
|
|
"2026-01-19T00:00:00.000Z", 1,
|
|
),
|
|
Messages: []db.Message{
|
|
syncMessage(
|
|
sessionID, 0, "assistant", "called tools",
|
|
"2026-01-19T00:01:00.000Z",
|
|
db.ToolCall{
|
|
ToolName: "legacy",
|
|
Category: "other",
|
|
ResultEvents: []db.ToolResultEvent{{
|
|
Source: "tool",
|
|
Status: "complete",
|
|
Content: first,
|
|
ContentLength: len(first),
|
|
Timestamp: "2026-01-19T00:02:00.000Z",
|
|
}},
|
|
},
|
|
db.ToolCall{
|
|
ToolName: "legacy",
|
|
Category: "other",
|
|
ResultEvents: []db.ToolResultEvent{{
|
|
Source: "tool",
|
|
Status: "complete",
|
|
Content: second,
|
|
ContentLength: len(second),
|
|
Timestamp: "2026-01-19T00:02:00.000Z",
|
|
}},
|
|
},
|
|
),
|
|
},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
}})
|
|
require.NoError(t, err)
|
|
syncer := newInMemoryTestSync(t, local, SyncOptions{})
|
|
_, err = syncer.Push(ctx, true, nil)
|
|
require.NoError(t, err)
|
|
store := NewStoreFromDB(syncer.DB())
|
|
|
|
for _, mode := range []string{"substring", "regex"} {
|
|
t.Run(mode, func(t *testing.T) {
|
|
filter := db.ContentSearchFilter{
|
|
Pattern: "event needle",
|
|
Sources: []string{"tool_result"},
|
|
IncludeOneShot: true,
|
|
Limit: 1,
|
|
}
|
|
if mode == "regex" {
|
|
filter.Mode = "regex"
|
|
filter.Pattern = `event\s+needle`
|
|
}
|
|
page, err := store.SearchContent(ctx, filter)
|
|
require.NoError(t, err)
|
|
require.Len(t, page.Matches, 1)
|
|
assert.Contains(t, page.Matches[0].Snippet, first)
|
|
require.NotZero(t, page.NextCursor)
|
|
|
|
filter.Cursor = page.NextCursor
|
|
page, err = store.SearchContent(ctx, filter)
|
|
require.NoError(t, err)
|
|
require.Len(t, page.Matches, 1)
|
|
assert.Contains(t, page.Matches[0].Snippet, second)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAnalyticsActivityMessageCountsRespectSessionFilter(t *testing.T) {
|
|
ctx := context.Background()
|
|
store, _ := newSyncedStore(t)
|
|
|
|
activity, err := store.GetAnalyticsActivity(ctx, db.AnalyticsFilter{
|
|
From: "2026-01-01",
|
|
To: "2026-01-31",
|
|
Project: "alpha",
|
|
}, "day")
|
|
require.NoError(t, err)
|
|
require.Len(t, activity.Series, 1)
|
|
assert.Equal(t, "2026-01-10", activity.Series[0].Date)
|
|
assert.Equal(t, 1, activity.Series[0].UserMessages)
|
|
assert.Equal(t, 1, activity.Series[0].AssistantMessages)
|
|
assert.Equal(t, 2, activity.Series[0].ByAgent["claude"])
|
|
}
|
|
|
|
func TestAnalyticsActivityCountsToolCallRows(t *testing.T) {
|
|
ctx := context.Background()
|
|
local := newLocalDB(t)
|
|
sessionID := "duck-activity-tool-rows"
|
|
first := `{"query":"alpha"}`
|
|
second := `{"query":"beta"}`
|
|
_, err := local.WriteSessionBatchAtomic([]db.SessionBatchWrite{{
|
|
Session: syncSession(
|
|
sessionID, "alpha", "activity tools",
|
|
"2026-01-23T00:00:00.000Z", 1,
|
|
),
|
|
Messages: []db.Message{
|
|
syncMessage(
|
|
sessionID, 0, "assistant", "called tools",
|
|
"2026-01-23T00:01:00.000Z",
|
|
db.ToolCall{
|
|
ToolName: "search",
|
|
Category: "search",
|
|
InputJSON: first,
|
|
},
|
|
db.ToolCall{
|
|
ToolName: "search",
|
|
Category: "search",
|
|
InputJSON: second,
|
|
},
|
|
),
|
|
},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
}})
|
|
require.NoError(t, err)
|
|
syncer := newInMemoryTestSync(t, local, SyncOptions{})
|
|
_, err = syncer.Push(ctx, true, nil)
|
|
require.NoError(t, err)
|
|
_, err = syncer.DB().ExecContext(ctx,
|
|
`UPDATE messages SET has_tool_use = FALSE WHERE session_id = ?`,
|
|
sessionID,
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
store := NewStoreFromDB(syncer.DB())
|
|
activity, err := store.GetAnalyticsActivity(ctx, db.AnalyticsFilter{
|
|
From: "2026-01-23",
|
|
To: "2026-01-23",
|
|
}, "day")
|
|
require.NoError(t, err)
|
|
require.Len(t, activity.Series, 1)
|
|
assert.Equal(t, 2, activity.Series[0].ToolCalls)
|
|
}
|
|
|
|
func TestAnalyticsActivitySkipsSystemUserMessages(t *testing.T) {
|
|
ctx := context.Background()
|
|
local := newLocalDB(t)
|
|
sessionID := "duck-activity-system"
|
|
systemMsg := syncMessage(sessionID, 0, "user", "system banner", "2026-01-23T00:00:00.000Z")
|
|
systemMsg.IsSystem = true
|
|
_, err := local.WriteSessionBatchAtomic([]db.SessionBatchWrite{{
|
|
Session: syncSession(sessionID, "alpha", "activity", "2026-01-23T00:00:00.000Z", 2),
|
|
Messages: []db.Message{
|
|
systemMsg,
|
|
syncMessage(sessionID, 1, "user", "real user", "2026-01-23T00:01:00.000Z"),
|
|
},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
}})
|
|
require.NoError(t, err)
|
|
syncer := newInMemoryTestSync(t, local, SyncOptions{})
|
|
_, err = syncer.Push(ctx, true, nil)
|
|
require.NoError(t, err)
|
|
store := NewStoreFromDB(syncer.DB())
|
|
|
|
activity, err := store.GetAnalyticsActivity(ctx, db.AnalyticsFilter{
|
|
From: "2026-01-23",
|
|
To: "2026-01-23",
|
|
}, "day")
|
|
require.NoError(t, err)
|
|
require.Len(t, activity.Series, 1)
|
|
assert.Equal(t, 2, activity.Series[0].Messages)
|
|
assert.Equal(t, 1, activity.Series[0].UserMessages)
|
|
assert.Equal(t, 2, activity.Series[0].ByAgent["claude"])
|
|
}
|
|
|
|
func TestAnalyticsSessionFiltersUseMessageTimeForHourAndDay(t *testing.T) {
|
|
ctx := context.Background()
|
|
local := newLocalDB(t)
|
|
_, err := local.WriteSessionBatchAtomic([]db.SessionBatchWrite{
|
|
{
|
|
Session: syncSession("duck-time-a", "alpha", "time a", "2026-01-21T01:00:00.000Z", 1),
|
|
Messages: []db.Message{
|
|
syncMessage("duck-time-a", 0, "user", "time a", "2026-01-21T09:15:00.000Z"),
|
|
},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
},
|
|
{
|
|
Session: syncSession("duck-time-b", "alpha", "time b", "2026-01-21T09:00:00.000Z", 1),
|
|
Messages: []db.Message{
|
|
syncMessage("duck-time-b", 0, "user", "time b", "2026-01-21T10:15:00.000Z"),
|
|
},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
syncer := newInMemoryTestSync(t, local, SyncOptions{})
|
|
_, err = syncer.Push(ctx, true, nil)
|
|
require.NoError(t, err)
|
|
store := NewStoreFromDB(syncer.DB())
|
|
|
|
hour := 9
|
|
dow := 2
|
|
summary, err := store.GetAnalyticsSummary(ctx, db.AnalyticsFilter{
|
|
From: "2026-01-21",
|
|
To: "2026-01-21",
|
|
Timezone: "UTC",
|
|
DayOfWeek: &dow,
|
|
Hour: &hour,
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 1, summary.TotalSessions)
|
|
}
|
|
|
|
func TestAnalyticsTerminationFilterUsesSharedStateSemantics(t *testing.T) {
|
|
ctx := context.Background()
|
|
local := newLocalDB(t)
|
|
clean := "clean"
|
|
pending := "tool_call_pending"
|
|
truncated := "truncated"
|
|
old := "2026-01-21T09:00:00.000Z"
|
|
|
|
cleanSession := syncSession("duck-term-clean", "alpha", "clean", old, 1)
|
|
cleanSession.TerminationStatus = &clean
|
|
pendingSession := syncSession("duck-term-pending", "alpha", "pending", old, 1)
|
|
pendingSession.TerminationStatus = &pending
|
|
truncatedSession := syncSession("duck-term-truncated", "alpha", "truncated", old, 1)
|
|
truncatedSession.TerminationStatus = &truncated
|
|
_, err := local.WriteSessionBatchAtomic([]db.SessionBatchWrite{
|
|
{
|
|
Session: cleanSession,
|
|
Messages: []db.Message{syncMessage(cleanSession.ID, 0, "user", "clean", old)},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
},
|
|
{
|
|
Session: pendingSession,
|
|
Messages: []db.Message{syncMessage(pendingSession.ID, 0, "user", "pending", old)},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
},
|
|
{
|
|
Session: truncatedSession,
|
|
Messages: []db.Message{syncMessage(truncatedSession.ID, 0, "user", "truncated", old)},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
syncer := newInMemoryTestSync(t, local, SyncOptions{})
|
|
_, err = syncer.Push(ctx, true, nil)
|
|
require.NoError(t, err)
|
|
store := NewStoreFromDB(syncer.DB())
|
|
|
|
summary, err := store.GetAnalyticsSummary(ctx, db.AnalyticsFilter{
|
|
From: "2026-01-21",
|
|
To: "2026-01-21",
|
|
Timezone: "UTC",
|
|
Termination: "unclean",
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 2, summary.TotalSessions)
|
|
}
|
|
|
|
func TestAnalyticsActiveSinceParsesEquivalentOffsets(t *testing.T) {
|
|
ctx := context.Background()
|
|
local := newLocalDB(t)
|
|
sessionID := "duck-active-since-offset"
|
|
session := syncSession(sessionID, "alpha", "offset active", "2026-01-21T08:00:00.000Z", 1)
|
|
endedAt := "2026-01-21T10:00:00.000Z"
|
|
session.EndedAt = &endedAt
|
|
session.LocalModifiedAt = &endedAt
|
|
|
|
_, err := local.WriteSessionBatchAtomic([]db.SessionBatchWrite{{
|
|
Session: session,
|
|
Messages: []db.Message{syncMessage(sessionID, 0, "user", "offset active", "2026-01-21T08:00:00.000Z")},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
}})
|
|
require.NoError(t, err)
|
|
|
|
syncer := newInMemoryTestSync(t, local, SyncOptions{})
|
|
_, err = syncer.Push(ctx, true, nil)
|
|
require.NoError(t, err)
|
|
store := NewStoreFromDB(syncer.DB())
|
|
|
|
summary, err := store.GetAnalyticsSummary(ctx, db.AnalyticsFilter{
|
|
ActiveSince: "2026-01-21T11:00:00+02:00",
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 1, summary.TotalSessions)
|
|
}
|
|
|
|
func TestAnalyticsHourOfWeekRespectsSessionFilters(t *testing.T) {
|
|
ctx := context.Background()
|
|
local := newLocalDB(t)
|
|
ts := "2026-01-21T09:15:00.000Z"
|
|
_, err := local.WriteSessionBatchAtomic([]db.SessionBatchWrite{
|
|
{
|
|
Session: syncSession("duck-how-alpha", "alpha", "hour alpha", ts, 1),
|
|
Messages: []db.Message{syncMessage("duck-how-alpha", 0, "user", "alpha", ts)},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
},
|
|
{
|
|
Session: syncSession("duck-how-beta", "beta", "hour beta", ts, 1),
|
|
Messages: []db.Message{syncMessage("duck-how-beta", 0, "user", "beta", ts)},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
syncer := newInMemoryTestSync(t, local, SyncOptions{})
|
|
_, err = syncer.Push(ctx, true, nil)
|
|
require.NoError(t, err)
|
|
store := NewStoreFromDB(syncer.DB())
|
|
|
|
got, err := store.GetAnalyticsHourOfWeek(ctx, db.AnalyticsFilter{
|
|
From: "2026-01-21",
|
|
To: "2026-01-21",
|
|
Timezone: "UTC",
|
|
Project: "alpha",
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 1, hourOfWeekMessages(got.Cells, 2, 9))
|
|
}
|
|
|
|
func TestAnalyticsHourOfWeekIncludesOvernightMessages(t *testing.T) {
|
|
ctx := context.Background()
|
|
local := newLocalDB(t)
|
|
start := "2026-01-21T23:30:00.000Z"
|
|
session := syncSession("duck-how-overnight", "alpha", "overnight", start, 2)
|
|
_, err := local.WriteSessionBatchAtomic([]db.SessionBatchWrite{
|
|
{
|
|
Session: session,
|
|
Messages: []db.Message{
|
|
syncMessage("duck-how-overnight", 0, "user", "before midnight", start),
|
|
syncMessage("duck-how-overnight", 1, "assistant", "after midnight",
|
|
"2026-01-22T00:30:00.000Z"),
|
|
},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
syncer := newInMemoryTestSync(t, local, SyncOptions{})
|
|
_, err = syncer.Push(ctx, true, nil)
|
|
require.NoError(t, err)
|
|
store := NewStoreFromDB(syncer.DB())
|
|
|
|
got, err := store.GetAnalyticsHourOfWeek(ctx, db.AnalyticsFilter{
|
|
From: "2026-01-21",
|
|
To: "2026-01-21",
|
|
Timezone: "UTC",
|
|
})
|
|
require.NoError(t, err)
|
|
// 2026-01-21 is a Wednesday (ISO dow 2). The session falls inside the
|
|
// date window, so all of its messages count, including the one whose
|
|
// local date crosses past the To bound.
|
|
assert.Equal(t, 1, hourOfWeekMessages(got.Cells, 2, 23))
|
|
assert.Equal(t, 1, hourOfWeekMessages(got.Cells, 3, 0))
|
|
}
|
|
|
|
func TestTrendsTermsApplySessionFiltersAndSystemPrefixExclusion(t *testing.T) {
|
|
ctx := context.Background()
|
|
local := newLocalDB(t)
|
|
start := "2026-01-22T09:00:00.000Z"
|
|
_, err := local.WriteSessionBatchAtomic([]db.SessionBatchWrite{
|
|
{
|
|
Session: syncSession("duck-trend-a", "alpha", "trend a", start, 2),
|
|
Messages: []db.Message{
|
|
syncMessage("duck-trend-a", 0, "user", db.SystemMsgPrefixes[0]+" seam", start),
|
|
syncMessage("duck-trend-a", 1, "user", "seam", start),
|
|
},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
},
|
|
{
|
|
Session: syncSession("duck-trend-b", "beta", "trend b", start, 1),
|
|
Messages: []db.Message{
|
|
syncMessage("duck-trend-b", 0, "user", "seam", start),
|
|
},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
syncer := newInMemoryTestSync(t, local, SyncOptions{})
|
|
_, err = syncer.Push(ctx, true, nil)
|
|
require.NoError(t, err)
|
|
store := NewStoreFromDB(syncer.DB())
|
|
|
|
trendTerms, err := db.ParseTrendTerms([]string{"seam"})
|
|
require.NoError(t, err)
|
|
trends, err := store.GetTrendsTerms(ctx, db.AnalyticsFilter{
|
|
From: "2026-01-22",
|
|
To: "2026-01-22",
|
|
Timezone: "UTC",
|
|
Project: "alpha",
|
|
}, trendTerms, "day")
|
|
require.NoError(t, err)
|
|
require.Len(t, trends.Series, 1)
|
|
assert.Equal(t, 1, trends.Series[0].Total)
|
|
}
|
|
|
|
func TestDailyUsageDefaultsToLocalTimezone(t *testing.T) {
|
|
oldLocal := time.Local
|
|
time.Local = time.FixedZone("DuckLocal", -5*60*60)
|
|
t.Cleanup(func() { time.Local = oldLocal })
|
|
|
|
ctx := context.Background()
|
|
local := newLocalDB(t)
|
|
require.NoError(t, local.UpsertModelPricing([]db.ModelPricing{{
|
|
ModelPattern: "claude-test",
|
|
InputPerMTok: 3,
|
|
OutputPerMTok: 15,
|
|
}}))
|
|
sessionID := "duck-usage-local-day"
|
|
_, err := local.WriteSessionBatchAtomic([]db.SessionBatchWrite{{
|
|
Session: syncSession(sessionID, "alpha", "local usage", "2026-01-02T02:00:00.000Z", 1),
|
|
Messages: []db.Message{
|
|
syncMessage(sessionID, 0, "assistant", "local usage", "2026-01-02T02:00:00.000Z"),
|
|
},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
}})
|
|
require.NoError(t, err)
|
|
syncer := newInMemoryTestSync(t, local, SyncOptions{})
|
|
_, err = syncer.Push(ctx, true, nil)
|
|
require.NoError(t, err)
|
|
store := NewStoreFromDB(syncer.DB())
|
|
|
|
got, err := store.GetDailyUsage(ctx, db.UsageFilter{
|
|
From: "2026-01-01",
|
|
To: "2026-01-01",
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, got.Daily, 1)
|
|
assert.Equal(t, "2026-01-01", got.Daily[0].Date)
|
|
assert.Equal(t, 1, got.Totals.InputTokens)
|
|
assert.Equal(t, 2, got.Totals.OutputTokens)
|
|
}
|
|
|
|
func TestDailyUsageActiveSinceUsesSessionActivity(t *testing.T) {
|
|
ctx := context.Background()
|
|
local := newLocalDB(t)
|
|
sessionID := "duck-usage-session-activity"
|
|
session := syncSession(sessionID, "alpha", "activity usage", "2026-01-01T00:00:00.000Z", 1)
|
|
endedAt := "2026-01-03T00:00:00.000Z"
|
|
session.EndedAt = &endedAt
|
|
session.LocalModifiedAt = &endedAt
|
|
|
|
_, err := local.WriteSessionBatchAtomic([]db.SessionBatchWrite{{
|
|
Session: session,
|
|
Messages: []db.Message{
|
|
syncMessage(sessionID, 0, "assistant", "activity usage", "2026-01-01T01:00:00.000Z"),
|
|
},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
}})
|
|
require.NoError(t, err)
|
|
|
|
syncer := newInMemoryTestSync(t, local, SyncOptions{})
|
|
_, err = syncer.Push(ctx, true, nil)
|
|
require.NoError(t, err)
|
|
store := NewStoreFromDB(syncer.DB())
|
|
|
|
got, err := store.GetDailyUsage(ctx, db.UsageFilter{
|
|
From: "2026-01-01",
|
|
To: "2026-01-01",
|
|
Timezone: "UTC",
|
|
ActiveSince: "2026-01-02T00:00:00.000Z",
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 1, got.Totals.InputTokens)
|
|
assert.Equal(t, 2, got.Totals.OutputTokens)
|
|
}
|
|
|
|
func TestDailyUsageHandlesBlankMessageTimestampWithoutSessionStart(t *testing.T) {
|
|
ctx := context.Background()
|
|
local := newLocalDB(t)
|
|
sessionID := "duck-usage-blank-ts"
|
|
session := syncSession(sessionID, "alpha", "blank timestamp usage", "", 2)
|
|
session.StartedAt = nil
|
|
|
|
_, err := local.WriteSessionBatchAtomic([]db.SessionBatchWrite{{
|
|
Session: session,
|
|
Messages: []db.Message{
|
|
{
|
|
SessionID: sessionID,
|
|
Ordinal: 0,
|
|
Role: "assistant",
|
|
Timestamp: "",
|
|
Model: "claude-test",
|
|
TokenUsage: json.RawMessage(`{"input_tokens":100,"output_tokens":50}`),
|
|
},
|
|
{
|
|
SessionID: sessionID,
|
|
Ordinal: 1,
|
|
Role: "assistant",
|
|
Timestamp: "",
|
|
Model: "claude-test",
|
|
TokenUsage: json.RawMessage(`{"input_tokens":200,"output_tokens":75}`),
|
|
},
|
|
},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
}})
|
|
require.NoError(t, err)
|
|
|
|
syncer := newInMemoryTestSync(t, local, SyncOptions{})
|
|
_, err = syncer.Push(ctx, true, nil)
|
|
require.NoError(t, err)
|
|
store := NewStoreFromDB(syncer.DB())
|
|
|
|
got, err := store.GetDailyUsage(ctx, db.UsageFilter{Timezone: "UTC"})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 300, got.Totals.InputTokens)
|
|
assert.Equal(t, 125, got.Totals.OutputTokens)
|
|
}
|
|
|
|
func hourOfWeekMessages(cells []db.HourOfWeekCell, dow, hour int) int {
|
|
for _, cell := range cells {
|
|
if cell.DayOfWeek == dow && cell.Hour == hour {
|
|
return cell.Messages
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func distributionCount(buckets []db.DistributionBucket, label string) int {
|
|
for _, bucket := range buckets {
|
|
if bucket.Label == label {
|
|
return bucket.Count
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func TestUsageDedupesClaudeMessageIDs(t *testing.T) {
|
|
ctx := context.Background()
|
|
local := newLocalDB(t)
|
|
require.NoError(t, local.UpsertModelPricing([]db.ModelPricing{{
|
|
ModelPattern: "claude-test",
|
|
InputPerMTok: 3,
|
|
OutputPerMTok: 15,
|
|
}}))
|
|
|
|
first := syncMessage("duck-usage-a", 0, "assistant", "shared usage", "2026-01-13T00:00:00.000Z")
|
|
first.ClaudeMessageID = "shared-message"
|
|
first.ClaudeRequestID = "shared-request"
|
|
second := syncMessage("duck-usage-b", 0, "assistant", "replayed usage", "2026-01-13T00:01:00.000Z")
|
|
second.ClaudeMessageID = "shared-message"
|
|
second.ClaudeRequestID = "shared-request"
|
|
|
|
_, err := local.WriteSessionBatchAtomic([]db.SessionBatchWrite{
|
|
{
|
|
Session: syncSession("duck-usage-a", "alpha", "usage a", "2026-01-13T00:00:00.000Z", 1),
|
|
Messages: []db.Message{first},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
},
|
|
{
|
|
Session: syncSession("duck-usage-b", "beta", "usage b", "2026-01-13T00:01:00.000Z", 1),
|
|
Messages: []db.Message{second},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
syncer := newInMemoryTestSync(t, local, SyncOptions{})
|
|
_, err = syncer.Push(ctx, true, nil)
|
|
require.NoError(t, err)
|
|
store := NewStoreFromDB(syncer.DB())
|
|
filter := db.UsageFilter{From: "2026-01-01", To: "2026-01-31"}
|
|
|
|
daily, err := store.GetDailyUsage(ctx, filter)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 1, daily.Totals.InputTokens)
|
|
assert.Equal(t, 2, daily.Totals.OutputTokens)
|
|
|
|
top, err := store.GetTopSessionsByCost(ctx, filter, 10)
|
|
require.NoError(t, err)
|
|
require.Len(t, top, 1)
|
|
assert.Equal(t, "duck-usage-a", top[0].SessionID)
|
|
|
|
counts, err := store.GetUsageSessionCounts(ctx, filter)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 1, counts.Total)
|
|
assert.Equal(t, 1, counts.ByProject["alpha"])
|
|
assert.NotContains(t, counts.ByProject, "beta")
|
|
|
|
sessionUsage, err := store.GetSessionUsage(ctx, "duck-usage-b", true)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, sessionUsage)
|
|
assert.True(t, sessionUsage.HasCost)
|
|
assert.InDelta(t, 0.000033, sessionUsage.CostUSD, 0.000001)
|
|
assert.Equal(t, []string{"claude-test"}, sessionUsage.Models)
|
|
require.Len(t, sessionUsage.Breakdown, 1)
|
|
entry := sessionUsage.Breakdown[0]
|
|
assert.Equal(t, 1, entry.Ordinal)
|
|
require.NotNil(t, entry.MessageOrdinal)
|
|
assert.Equal(t, 0, *entry.MessageOrdinal)
|
|
assert.Equal(t, "message", entry.Source)
|
|
assert.Equal(t, "Prompt 1", entry.Label)
|
|
assert.Equal(t, "2026-01-13T00:01:00Z", entry.Timestamp)
|
|
assert.Equal(t, "claude-test", entry.Model)
|
|
assert.Equal(t, 1, entry.InputTokens)
|
|
assert.Equal(t, 2, entry.OutputTokens)
|
|
assert.True(t, entry.HasCost)
|
|
assert.InDelta(t, 0.000033, entry.CostUSD, 0.000001)
|
|
}
|
|
|
|
func TestUsageDedupesSourceUUIDWhenClaudePairIncomplete(t *testing.T) {
|
|
ctx := context.Background()
|
|
local := newLocalDB(t)
|
|
require.NoError(t, local.UpsertModelPricing([]db.ModelPricing{{
|
|
ModelPattern: "claude-test",
|
|
InputPerMTok: 3,
|
|
OutputPerMTok: 15,
|
|
}}))
|
|
|
|
first := syncMessage("duck-usage-source-a", 0, "assistant", "shared usage", "2026-01-13T00:00:00.000Z")
|
|
first.ClaudeMessageID = "shared-message"
|
|
first.SourceUUID = "shared-source"
|
|
second := syncMessage("duck-usage-source-b", 0, "assistant", "replayed usage", "2026-01-13T00:01:00.000Z")
|
|
second.ClaudeMessageID = "shared-message"
|
|
second.SourceUUID = "shared-source"
|
|
|
|
_, err := local.WriteSessionBatchAtomic([]db.SessionBatchWrite{
|
|
{
|
|
Session: syncSession("duck-usage-source-a", "alpha", "usage a", "2026-01-13T00:00:00.000Z", 1),
|
|
Messages: []db.Message{first},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
},
|
|
{
|
|
Session: syncSession("duck-usage-source-b", "beta", "usage b", "2026-01-13T00:01:00.000Z", 1),
|
|
Messages: []db.Message{second},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
syncer := newInMemoryTestSync(t, local, SyncOptions{})
|
|
_, err = syncer.Push(ctx, true, nil)
|
|
require.NoError(t, err)
|
|
store := NewStoreFromDB(syncer.DB())
|
|
filter := db.UsageFilter{From: "2026-01-01", To: "2026-01-31"}
|
|
|
|
daily, err := store.GetDailyUsage(ctx, filter)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 1, daily.Totals.InputTokens)
|
|
assert.Equal(t, 2, daily.Totals.OutputTokens)
|
|
|
|
top, err := store.GetTopSessionsByCost(ctx, filter, 10)
|
|
require.NoError(t, err)
|
|
require.Len(t, top, 1)
|
|
assert.Equal(t, "duck-usage-source-a", top[0].SessionID)
|
|
|
|
counts, err := store.GetUsageSessionCounts(ctx, filter)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 1, counts.Total)
|
|
assert.Equal(t, 1, counts.ByProject["alpha"])
|
|
assert.NotContains(t, counts.ByProject, "beta")
|
|
|
|
sessionUsage, err := store.GetSessionUsage(ctx, "duck-usage-source-b", true)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, sessionUsage)
|
|
assert.True(t, sessionUsage.HasCost)
|
|
assert.InDelta(t, 0.000033, sessionUsage.CostUSD, 0.000001)
|
|
assert.Equal(t, []string{"claude-test"}, sessionUsage.Models)
|
|
require.Len(t, sessionUsage.Breakdown, 1)
|
|
require.NotNil(t, sessionUsage.Breakdown[0].MessageOrdinal)
|
|
assert.Equal(t, 0, *sessionUsage.Breakdown[0].MessageOrdinal)
|
|
}
|
|
|
|
func TestUsagePreservesSessionSummaryUsageEventTokens(t *testing.T) {
|
|
ctx := context.Background()
|
|
local := newLocalDB(t)
|
|
require.NoError(t, local.UpsertModelPricing([]db.ModelPricing{{
|
|
ModelPattern: "summary-model",
|
|
InputPerMTok: 1,
|
|
OutputPerMTok: 2,
|
|
}}))
|
|
|
|
rawInput := db.MaxPlausibleTokens + 250_000
|
|
rawOutput := db.MaxPlausibleTokens + 500_000
|
|
sessionID := "duck-summary-usage"
|
|
sess := syncSession(sessionID, "alpha", "summary first", "2026-01-18T00:00:00.000Z", 0)
|
|
sess.Agent = "hermes"
|
|
sess.TotalOutputTokens = rawOutput
|
|
sess.PeakContextTokens = rawInput
|
|
sess.HasTotalOutputTokens = true
|
|
sess.HasPeakContextTokens = true
|
|
|
|
_, err := local.WriteSessionBatchAtomic([]db.SessionBatchWrite{{
|
|
Session: sess,
|
|
UsageEvents: []db.UsageEvent{{
|
|
Source: "session",
|
|
Model: "summary-model",
|
|
InputTokens: rawInput,
|
|
OutputTokens: rawOutput,
|
|
OccurredAt: "2026-01-18T00:01:00.000Z",
|
|
DedupKey: "summary",
|
|
}},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
}})
|
|
require.NoError(t, err)
|
|
|
|
syncer := newInMemoryTestSync(t, local, SyncOptions{})
|
|
_, err = syncer.Push(ctx, true, nil)
|
|
require.NoError(t, err)
|
|
store := NewStoreFromDB(syncer.DB())
|
|
filter := db.UsageFilter{From: "2026-01-01", To: "2026-01-31", Timezone: "UTC"}
|
|
|
|
daily, err := store.GetDailyUsage(ctx, filter)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, rawInput, daily.Totals.InputTokens)
|
|
assert.Equal(t, rawOutput, daily.Totals.OutputTokens)
|
|
|
|
top, err := store.GetTopSessionsByCost(ctx, filter, 10)
|
|
require.NoError(t, err)
|
|
require.Len(t, top, 1)
|
|
assert.Equal(t, sessionID, top[0].SessionID)
|
|
assert.Equal(t, rawInput+rawOutput, top[0].TotalTokens)
|
|
wantCost := (float64(rawInput)*1 + float64(rawOutput)*2) / 1_000_000
|
|
assert.InDelta(t, wantCost, top[0].Cost, 0.000001)
|
|
|
|
sessionUsage, err := store.GetSessionUsage(ctx, sessionID, true)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, sessionUsage)
|
|
assert.Equal(t, rawOutput, sessionUsage.TotalOutputTokens)
|
|
assert.Equal(t, rawInput, sessionUsage.PeakContextTokens)
|
|
assert.True(t, sessionUsage.HasCost)
|
|
assert.InDelta(t, wantCost, sessionUsage.CostUSD, 0.000001)
|
|
assert.Equal(t, []string{"summary-model"}, sessionUsage.Models)
|
|
require.Len(t, sessionUsage.Breakdown, 1)
|
|
entry := sessionUsage.Breakdown[0]
|
|
assert.Equal(t, "session", entry.Source)
|
|
assert.Equal(t, "session", entry.Label)
|
|
assert.Nil(t, 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, 0.000001)
|
|
}
|
|
|
|
func TestDailyUsageCostsReasoningOnlyRows(t *testing.T) {
|
|
ctx := context.Background()
|
|
local := newLocalDB(t)
|
|
require.NoError(t, local.UpsertModelPricing([]db.ModelPricing{{
|
|
ModelPattern: "reasoning-only",
|
|
OutputPerMTok: 2,
|
|
}}))
|
|
|
|
sessionID := "duck-reasoning-only"
|
|
_, err := local.WriteSessionBatchAtomic([]db.SessionBatchWrite{{
|
|
Session: syncSession(
|
|
sessionID, "alpha", "reasoning only",
|
|
"2026-01-19T00:00:00.000Z", 0),
|
|
UsageEvents: []db.UsageEvent{{
|
|
Source: "provider",
|
|
Model: "reasoning-only",
|
|
ReasoningTokens: 300,
|
|
OccurredAt: "2026-01-19T00:01:00.000Z",
|
|
DedupKey: "reasoning-only",
|
|
}},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
}})
|
|
require.NoError(t, err)
|
|
|
|
syncer := newInMemoryTestSync(t, local, SyncOptions{})
|
|
_, err = syncer.Push(ctx, true, nil)
|
|
require.NoError(t, err)
|
|
store := NewStoreFromDB(syncer.DB())
|
|
filter := db.UsageFilter{From: "2026-01-01", To: "2026-01-31", Timezone: "UTC"}
|
|
wantCost := float64(300) * 2 / 1_000_000
|
|
|
|
daily, err := store.GetDailyUsage(ctx, filter)
|
|
require.NoError(t, err)
|
|
assert.Zero(t, daily.Totals.OutputTokens,
|
|
"reasoning-only rows do not change reported output-token totals")
|
|
assert.InDelta(t, wantCost, daily.Totals.TotalCost, 0.000001)
|
|
|
|
top, err := store.GetTopSessionsByCost(ctx, filter, 10)
|
|
require.NoError(t, err)
|
|
require.Len(t, top, 1)
|
|
assert.InDelta(t, wantCost, top[0].Cost, 0.000001)
|
|
|
|
sessionUsage, err := store.GetSessionUsage(ctx, sessionID, true)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, sessionUsage)
|
|
assert.True(t, sessionUsage.HasCost)
|
|
assert.InDelta(t, wantCost, sessionUsage.CostUSD, 0.000001)
|
|
require.Len(t, sessionUsage.Breakdown, 1,
|
|
"reasoning-only rows must appear in the breakdown")
|
|
entry := sessionUsage.Breakdown[0]
|
|
assert.Equal(t, "provider", entry.Source)
|
|
assert.Zero(t, entry.OutputTokens,
|
|
"reasoning stays out of reported output tokens")
|
|
assert.True(t, entry.HasCost)
|
|
assert.InDelta(t, wantCost, entry.CostUSD, 0.000001,
|
|
"reasoning-only breakdown row bills at the output rate")
|
|
}
|
|
|
|
func TestDailyUsageCostsMessageReasoningTokens(t *testing.T) {
|
|
ctx := context.Background()
|
|
local := newLocalDB(t)
|
|
require.NoError(t, local.UpsertModelPricing([]db.ModelPricing{{
|
|
ModelPattern: "gpt-5.4",
|
|
InputPerMTok: 1,
|
|
OutputPerMTok: 2,
|
|
}}))
|
|
|
|
msg := syncMessage(
|
|
"duck-message-reasoning", 0, "assistant", "message reasoning",
|
|
"2026-01-19T00:01:00.000Z")
|
|
msg.Model = "gpt-5.4"
|
|
msg.TokenUsage = json.RawMessage(
|
|
`{"input_tokens":1000,"output_tokens":0,"reasoning_tokens":500}`)
|
|
_, err := local.WriteSessionBatchAtomic([]db.SessionBatchWrite{{
|
|
Session: syncSession(
|
|
"duck-message-reasoning", "alpha", "message reasoning",
|
|
"2026-01-19T00:00:00.000Z", 1),
|
|
Messages: []db.Message{msg},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
}})
|
|
require.NoError(t, err)
|
|
|
|
syncer := newInMemoryTestSync(t, local, SyncOptions{})
|
|
_, err = syncer.Push(ctx, true, nil)
|
|
require.NoError(t, err)
|
|
store := NewStoreFromDB(syncer.DB())
|
|
filter := db.UsageFilter{From: "2026-01-01", To: "2026-01-31", Timezone: "UTC"}
|
|
|
|
daily, err := store.GetDailyUsage(ctx, filter)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 1000, daily.Totals.InputTokens)
|
|
assert.Zero(t, daily.Totals.OutputTokens)
|
|
assert.InDelta(t, 0.002, daily.Totals.TotalCost, 0.000001)
|
|
|
|
sessionUsage, err := store.GetSessionUsage(ctx, "duck-message-reasoning", true)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, sessionUsage)
|
|
assert.True(t, sessionUsage.HasCost)
|
|
assert.InDelta(t, 0.002, sessionUsage.CostUSD, 0.000001)
|
|
require.Len(t, sessionUsage.Breakdown, 1,
|
|
"reasoning-bearing message must appear in the breakdown")
|
|
entry := sessionUsage.Breakdown[0]
|
|
assert.Equal(t, "message", entry.Source)
|
|
assert.Equal(t, 1000, entry.InputTokens)
|
|
assert.Zero(t, entry.OutputTokens)
|
|
assert.True(t, entry.HasCost)
|
|
assert.InDelta(t, 0.002, entry.CostUSD, 0.000001,
|
|
"breakdown cost must include reasoning billed as output")
|
|
}
|
|
|
|
func TestDailyUsageCostsMixedOutputAndReasoningOnlyRows(t *testing.T) {
|
|
ctx := context.Background()
|
|
local := newLocalDB(t)
|
|
require.NoError(t, local.UpsertModelPricing([]db.ModelPricing{{
|
|
ModelPattern: "reasoning-mix",
|
|
OutputPerMTok: 2,
|
|
}}))
|
|
|
|
sessionID := "duck-reasoning-mixed"
|
|
_, err := local.WriteSessionBatchAtomic([]db.SessionBatchWrite{{
|
|
Session: syncSession(
|
|
sessionID, "alpha", "reasoning mixed",
|
|
"2026-01-19T00:00:00.000Z", 0),
|
|
UsageEvents: []db.UsageEvent{
|
|
{
|
|
Source: "provider",
|
|
Model: "reasoning-mix",
|
|
OutputTokens: 100,
|
|
ReasoningTokens: 20,
|
|
OccurredAt: "2026-01-19T00:01:00.000Z",
|
|
DedupKey: "normal-output",
|
|
},
|
|
{
|
|
Source: "provider",
|
|
Model: "reasoning-mix",
|
|
ReasoningTokens: 300,
|
|
OccurredAt: "2026-01-19T00:02:00.000Z",
|
|
DedupKey: "reasoning-only",
|
|
},
|
|
},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
}})
|
|
require.NoError(t, err)
|
|
|
|
syncer := newInMemoryTestSync(t, local, SyncOptions{})
|
|
_, err = syncer.Push(ctx, true, nil)
|
|
require.NoError(t, err)
|
|
store := NewStoreFromDB(syncer.DB())
|
|
filter := db.UsageFilter{From: "2026-01-01", To: "2026-01-31", Timezone: "UTC"}
|
|
wantCost := float64(100+300) * 2 / 1_000_000
|
|
|
|
daily, err := store.GetDailyUsage(ctx, filter)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 100, daily.Totals.OutputTokens,
|
|
"reasoning-only rows do not change reported output-token totals")
|
|
assert.InDelta(t, wantCost, daily.Totals.TotalCost, 0.000001)
|
|
require.NotNil(t, daily.Pricing)
|
|
assert.Equal(t, export.CostSourceComputed,
|
|
daily.Pricing.Models["reasoning-mix"].CostSource)
|
|
|
|
top, err := store.GetTopSessionsByCost(ctx, filter, 10)
|
|
require.NoError(t, err)
|
|
require.Len(t, top, 1)
|
|
assert.InDelta(t, wantCost, top[0].Cost, 0.000001)
|
|
|
|
sessionUsage, err := store.GetSessionUsage(ctx, sessionID, true)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, sessionUsage)
|
|
assert.True(t, sessionUsage.HasCost)
|
|
assert.InDelta(t, wantCost, sessionUsage.CostUSD, 0.000001)
|
|
require.Len(t, sessionUsage.Breakdown, 2,
|
|
"both output and reasoning-only rows must appear in the breakdown")
|
|
breakdownCost := 0.0
|
|
for _, entry := range sessionUsage.Breakdown {
|
|
assert.True(t, entry.HasCost)
|
|
breakdownCost += entry.CostUSD
|
|
}
|
|
assert.InDelta(t, wantCost, breakdownCost, 0.000001,
|
|
"breakdown costs must sum to the session cost")
|
|
}
|
|
|
|
func TestUsageDedupPrefersInRangeDuplicate(t *testing.T) {
|
|
ctx := context.Background()
|
|
local := newLocalDB(t)
|
|
require.NoError(t, local.UpsertModelPricing([]db.ModelPricing{{
|
|
ModelPattern: "claude-test",
|
|
InputPerMTok: 3,
|
|
OutputPerMTok: 15,
|
|
}}))
|
|
|
|
before := syncMessage("duck-usage-edge-a", 0, "assistant", "before midnight", "2026-01-12T23:30:00.000Z")
|
|
before.ClaudeMessageID = "edge-message"
|
|
before.ClaudeRequestID = "edge-request"
|
|
after := syncMessage("duck-usage-edge-b", 0, "assistant", "after midnight", "2026-01-13T00:30:00.000Z")
|
|
after.ClaudeMessageID = "edge-message"
|
|
after.ClaudeRequestID = "edge-request"
|
|
|
|
_, err := local.WriteSessionBatchAtomic([]db.SessionBatchWrite{
|
|
{
|
|
Session: syncSession("duck-usage-edge-a", "alpha", "edge a", "2026-01-12T23:30:00.000Z", 1),
|
|
Messages: []db.Message{before},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
},
|
|
{
|
|
Session: syncSession("duck-usage-edge-b", "alpha", "edge b", "2026-01-13T00:30:00.000Z", 1),
|
|
Messages: []db.Message{after},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
syncer := newInMemoryTestSync(t, local, SyncOptions{})
|
|
_, err = syncer.Push(ctx, true, nil)
|
|
require.NoError(t, err)
|
|
store := NewStoreFromDB(syncer.DB())
|
|
|
|
// The duplicate before midnight is outside the window but inside
|
|
// the padded UTC bounds and sorts first by timestamp. It must not
|
|
// win the dedup and suppress the in-range duplicate.
|
|
got, err := store.GetDailyUsage(ctx, db.UsageFilter{
|
|
From: "2026-01-13", To: "2026-01-13", Timezone: "UTC",
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 1, got.Totals.InputTokens)
|
|
assert.Equal(t, 2, got.Totals.OutputTokens)
|
|
}
|
|
|
|
func TestPushSyncsCursorUsageEventsIntoDuckDBDailyUsage(t *testing.T) {
|
|
ctx := context.Background()
|
|
local := newLocalDB(t)
|
|
require.NoError(t, local.InsertCursorUsageEvents([]db.CursorUsageEvent{{
|
|
OccurredAt: "2026-05-14T10:05:00Z",
|
|
Model: "claude-4.6-opus-high-thinking",
|
|
Kind: "USAGE_EVENT_KIND_USAGE_BASED",
|
|
InputTokens: 1234,
|
|
OutputTokens: 567,
|
|
CacheWriteTokens: 12,
|
|
CacheReadTokens: 34,
|
|
ChargedCents: 15.66,
|
|
CursorTokenFee: 3.32,
|
|
UserID: "152683922",
|
|
UserEmail: "member@example.com",
|
|
IsHeadless: false,
|
|
}}), "InsertCursorUsageEvents")
|
|
|
|
syncer := newInMemoryTestSync(t, local, SyncOptions{})
|
|
_, err := syncer.Push(ctx, false, nil)
|
|
require.NoError(t, err)
|
|
assertDuckDBCount(t, syncer.DB(), "cursor_usage_events", 1)
|
|
|
|
store := NewStoreFromDB(syncer.DB())
|
|
result, err := store.GetDailyUsage(ctx, db.UsageFilter{
|
|
From: "2026-05-14",
|
|
To: "2026-05-14",
|
|
Timezone: "UTC",
|
|
Breakdowns: true,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, result.Daily, 1)
|
|
assert.Equal(t, 1234, result.Daily[0].InputTokens)
|
|
assert.Equal(t, 567, result.Daily[0].OutputTokens)
|
|
assert.Equal(t, 12, result.Daily[0].CacheCreationTokens)
|
|
assert.Equal(t, 34, result.Daily[0].CacheReadTokens)
|
|
assert.InDelta(t, 0.1566, result.Daily[0].TotalCost, 1e-9)
|
|
assert.Empty(t, result.Projects, "cursor-only usage should not emit project identities")
|
|
assert.NotContains(t, result.Projects, "")
|
|
assert.Equal(t, 0, result.SessionCounts.Total)
|
|
assert.Empty(t, result.SessionCounts.ByAgent)
|
|
assert.Empty(t, result.SessionCounts.ByProject)
|
|
require.Len(t, result.Daily[0].AgentBreakdowns, 1)
|
|
assert.Equal(t, "cursor", result.Daily[0].AgentBreakdowns[0].Agent)
|
|
}
|
|
|
|
func TestTrendsTermsWordBoundaryAndOverlapParity(t *testing.T) {
|
|
ctx := context.Background()
|
|
local := newLocalDB(t)
|
|
start := "2026-01-22T09:00:00.000Z"
|
|
content := "seam seams seamless testing test attest"
|
|
_, err := local.WriteSessionBatchAtomic([]db.SessionBatchWrite{{
|
|
Session: syncSession("duck-trend-parity", "alpha", "trend parity", start, 1),
|
|
Messages: []db.Message{
|
|
syncMessage("duck-trend-parity", 0, "user", content, start),
|
|
},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
}})
|
|
require.NoError(t, err)
|
|
syncer := newInMemoryTestSync(t, local, SyncOptions{})
|
|
_, err = syncer.Push(ctx, true, nil)
|
|
require.NoError(t, err)
|
|
store := NewStoreFromDB(syncer.DB())
|
|
|
|
terms, err := db.ParseTrendTerms([]string{"seam", "test|testing"})
|
|
require.NoError(t, err)
|
|
filter := db.AnalyticsFilter{
|
|
From: "2026-01-22", To: "2026-01-22", Timezone: "UTC",
|
|
}
|
|
|
|
got, err := store.GetTrendsTerms(ctx, filter, terms, "day")
|
|
require.NoError(t, err)
|
|
require.Len(t, got.Series, 2)
|
|
// Word-bounded: "seamless" does not count for "seam", and
|
|
// "testing" is not double-counted via its "test" substring.
|
|
assert.Equal(t, 2, got.Series[0].Total)
|
|
assert.Equal(t, 2, got.Series[1].Total)
|
|
|
|
want, err := local.GetTrendsTerms(ctx, filter, terms, "day")
|
|
require.NoError(t, err)
|
|
require.Len(t, want.Series, 2)
|
|
assert.Equal(t, want.Series[0].Total, got.Series[0].Total)
|
|
assert.Equal(t, want.Series[1].Total, got.Series[1].Total)
|
|
}
|
|
|
|
func TestDailyUsageBreakdownsAndCacheSavings(t *testing.T) {
|
|
ctx := context.Background()
|
|
local := newLocalDB(t)
|
|
require.NoError(t, local.UpsertModelPricing([]db.ModelPricing{{
|
|
ModelPattern: "claude-test",
|
|
InputPerMTok: 3,
|
|
OutputPerMTok: 15,
|
|
CacheCreationPerMTok: 1,
|
|
CacheReadPerMTok: 0.5,
|
|
}}))
|
|
sessionID := "duck-usage-breakdowns"
|
|
_, err := local.WriteSessionBatchAtomic([]db.SessionBatchWrite{{
|
|
Session: syncSession(sessionID, "alpha", "usage first", "2026-01-17T00:00:00.000Z", 1),
|
|
Messages: []db.Message{syncMessage(sessionID, 0, "user", "usage first", "2026-01-17T00:00:00.000Z")},
|
|
UsageEvents: []db.UsageEvent{{
|
|
Source: "hermes",
|
|
Model: "claude-test",
|
|
InputTokens: 10,
|
|
OutputTokens: 5,
|
|
CacheReadInputTokens: 4,
|
|
OccurredAt: "2026-01-17T00:01:00.000Z",
|
|
DedupKey: "breakdown",
|
|
}},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
}})
|
|
require.NoError(t, err)
|
|
|
|
syncer := newInMemoryTestSync(t, local, SyncOptions{})
|
|
_, err = syncer.Push(ctx, true, nil)
|
|
require.NoError(t, err)
|
|
store := NewStoreFromDB(syncer.DB())
|
|
|
|
got, err := store.GetDailyUsage(ctx, db.UsageFilter{
|
|
From: "2026-01-01",
|
|
To: "2026-01-31",
|
|
Breakdowns: true,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, got.Daily, 1)
|
|
day := got.Daily[0]
|
|
require.Len(t, day.ModelBreakdowns, 1)
|
|
require.Len(t, day.ProjectBreakdowns, 1)
|
|
require.Len(t, day.AgentBreakdowns, 1)
|
|
assert.Equal(t, "alpha", day.ProjectBreakdowns[0].Project)
|
|
assert.Equal(t, "claude", day.AgentBreakdowns[0].Agent)
|
|
assert.InDelta(t, 0.00001, got.Totals.CacheSavings, 0.000001)
|
|
|
|
noCounts, err := store.GetDailyUsage(ctx, db.UsageFilter{
|
|
From: "2026-01-01",
|
|
To: "2026-01-31",
|
|
SkipSessionCounts: true,
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, got.Totals.InputTokens, noCounts.Totals.InputTokens)
|
|
assert.Zero(t, noCounts.SessionCounts.Total)
|
|
assert.Nil(t, noCounts.SessionCounts.ByProject)
|
|
assert.Nil(t, noCounts.SessionCounts.ByAgent)
|
|
}
|
|
|
|
func TestGetChildSessionsOrderedByStartedAt(t *testing.T) {
|
|
ctx := context.Background()
|
|
local := newLocalDB(t)
|
|
|
|
parent := syncSession("duck-parent", "alpha", "parent first", "2026-01-10T00:00:00.000Z", 1)
|
|
early := syncSession("duck-child-early", "alpha", "early child", "2026-01-10T01:00:00.000Z", 1)
|
|
late := syncSession("duck-child-late", "alpha", "late child", "2026-01-10T02:00:00.000Z", 1)
|
|
deleted := syncSession("duck-child-deleted", "alpha", "deleted child", "2026-01-10T01:30:00.000Z", 1)
|
|
parentID := parent.ID
|
|
for _, child := range []*db.Session{&early, &late, &deleted} {
|
|
child.ParentSessionID = &parentID
|
|
child.RelationshipType = "subagent"
|
|
}
|
|
|
|
writes := make([]db.SessionBatchWrite, 0, 4)
|
|
for _, sess := range []db.Session{parent, early, late, deleted} {
|
|
writes = append(writes, db.SessionBatchWrite{
|
|
Session: sess,
|
|
Messages: []db.Message{syncMessage(sess.ID, 0, "user", *sess.FirstMessage, *sess.StartedAt)},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
})
|
|
}
|
|
_, err := local.WriteSessionBatchAtomic(writes)
|
|
require.NoError(t, err)
|
|
require.NoError(t, local.SoftDeleteSession("duck-child-deleted"))
|
|
|
|
syncer := newInMemoryTestSync(t, local, SyncOptions{})
|
|
_, err = syncer.Push(ctx, true, nil)
|
|
require.NoError(t, err)
|
|
store := NewStoreFromDB(syncer.DB())
|
|
|
|
children, err := store.GetChildSessions(ctx, "duck-parent")
|
|
require.NoError(t, err)
|
|
assert.Equal(t,
|
|
[]string{"duck-child-early", "duck-child-late"},
|
|
duckSessionIDs(children))
|
|
}
|
|
|
|
// TestDuckGetAnalyticsSkillsAggregatesAcrossWeeks exercises the SQL
|
|
// pushdown path: COUNT(*) aggregation per message timestamp and trend
|
|
// buckets spread across the weeks a skill was actually used.
|
|
func TestDuckGetAnalyticsSkillsAggregatesAcrossWeeks(t *testing.T) {
|
|
ctx := context.Background()
|
|
local := newLocalDB(t)
|
|
|
|
const sid = "dk-multi"
|
|
skill := func(use string) db.ToolCall {
|
|
return db.ToolCall{
|
|
ToolName: "Skill", Category: "Skill",
|
|
SkillName: "deploy", ToolUseID: use,
|
|
}
|
|
}
|
|
writes := []db.SessionBatchWrite{{
|
|
Session: syncSession(sid, "alpha", "first",
|
|
"2026-01-06T09:00:00.000Z", 3),
|
|
Messages: []db.Message{
|
|
syncMessage(sid, 0, "user", "go", "2026-01-06T09:00:00.000Z"),
|
|
syncMessage(sid, 1, "assistant", "two calls",
|
|
"2026-01-06T10:00:00.000Z",
|
|
skill("tu-1"), skill("tu-2")),
|
|
syncMessage(sid, 2, "assistant", "one call",
|
|
"2026-01-20T10:00:00.000Z",
|
|
skill("tu-3")),
|
|
},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
}}
|
|
_, err := local.WriteSessionBatchAtomic(writes)
|
|
require.NoError(t, err)
|
|
|
|
syncer := newInMemoryTestSync(t, local, SyncOptions{})
|
|
_, err = syncer.Push(ctx, true, nil)
|
|
require.NoError(t, err)
|
|
store := NewStoreFromDB(syncer.DB())
|
|
|
|
resp, err := store.GetAnalyticsSkills(ctx, db.AnalyticsFilter{
|
|
From: "2026-01-01", To: "2026-01-31", Timezone: "UTC",
|
|
}, "week")
|
|
require.NoError(t, err, "GetAnalyticsSkills")
|
|
require.Len(t, resp.BySkill, 1, "BySkill")
|
|
assert.Equal(t, "deploy", resp.BySkill[0].SkillName)
|
|
assert.Equal(t, 3, resp.BySkill[0].CallCount, "CallCount")
|
|
assert.Equal(t, 1, resp.BySkill[0].SessionCount, "SessionCount")
|
|
assert.Equal(t, "2026-01-20T10:00:00Z", resp.BySkill[0].LastUsedAt,
|
|
"LastUsedAt is the latest message timestamp")
|
|
|
|
trend := map[string]int{}
|
|
for _, e := range resp.Trend {
|
|
if c := e.BySkill["deploy"]; c > 0 {
|
|
trend[e.Date] += c
|
|
}
|
|
}
|
|
assert.Equal(t, map[string]int{"2026-01-05": 2, "2026-01-19": 1}, trend,
|
|
"calls bucket into their own message-timestamp weeks")
|
|
}
|
|
|
|
// TestDuckGetAnalyticsSkillsFiltersByMessageDate checks that the date
|
|
// filter applies to each call's message timestamp, not the session start:
|
|
// a session that started before the range still contributes its in-range
|
|
// call, and its out-of-range calls are dropped.
|
|
func TestDuckGetAnalyticsSkillsFiltersByMessageDate(t *testing.T) {
|
|
ctx := context.Background()
|
|
local := newLocalDB(t)
|
|
|
|
const sid = "dk-span"
|
|
skill := func() db.ToolCall {
|
|
return db.ToolCall{
|
|
ToolName: "Skill", Category: "Skill", SkillName: "deploy",
|
|
}
|
|
}
|
|
writes := []db.SessionBatchWrite{{
|
|
Session: syncSession(sid, "alpha", "first",
|
|
"2026-01-20T09:00:00.000Z", 4),
|
|
Messages: []db.Message{
|
|
syncMessage(sid, 0, "user", "go", "2026-01-20T09:00:00.000Z"),
|
|
syncMessage(sid, 1, "assistant", "before",
|
|
"2026-01-25T10:00:00.000Z", skill()),
|
|
syncMessage(sid, 2, "assistant", "inrange",
|
|
"2026-02-10T10:00:00.000Z", skill()),
|
|
syncMessage(sid, 3, "assistant", "after",
|
|
"2026-03-05T10:00:00.000Z", skill()),
|
|
},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
}}
|
|
_, err := local.WriteSessionBatchAtomic(writes)
|
|
require.NoError(t, err)
|
|
|
|
syncer := newInMemoryTestSync(t, local, SyncOptions{})
|
|
_, err = syncer.Push(ctx, true, nil)
|
|
require.NoError(t, err)
|
|
store := NewStoreFromDB(syncer.DB())
|
|
|
|
resp, err := store.GetAnalyticsSkills(ctx, db.AnalyticsFilter{
|
|
From: "2026-02-01", To: "2026-02-28", Timezone: "UTC",
|
|
}, "week")
|
|
require.NoError(t, err, "GetAnalyticsSkills")
|
|
require.Len(t, resp.BySkill, 1, "BySkill")
|
|
assert.Equal(t, "deploy", resp.BySkill[0].SkillName)
|
|
assert.Equal(t, 1, resp.BySkill[0].CallCount,
|
|
"only the in-range call counts")
|
|
assert.Equal(t, "2026-02-10T10:00:00Z", resp.BySkill[0].LastUsedAt)
|
|
|
|
trend := map[string]int{}
|
|
for _, e := range resp.Trend {
|
|
if c := e.BySkill["deploy"]; c > 0 {
|
|
trend[e.Date] += c
|
|
}
|
|
}
|
|
assert.Equal(t, map[string]int{"2026-02-09": 1}, trend,
|
|
"only the in-range week is bucketed")
|
|
}
|
|
|
|
func newSyncedStore(t *testing.T) (*Store, syncFixture) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
local := newLocalDB(t)
|
|
fixture := seedDuckDBSyncFixture(t, local)
|
|
syncer := newInMemoryTestSync(t, local, SyncOptions{})
|
|
_, err := syncer.Push(ctx, true, nil)
|
|
require.NoError(t, err)
|
|
return NewStoreFromDB(syncer.DB()), fixture
|
|
}
|
|
|
|
func TestDuckDBBranchDimension(t *testing.T) {
|
|
ctx := context.Background()
|
|
local := newLocalDB(t)
|
|
require.NoError(t, local.UpsertModelPricing([]db.ModelPricing{{
|
|
ModelPattern: "claude-test", InputPerMTok: 3, OutputPerMTok: 15,
|
|
}}))
|
|
|
|
seed := []struct {
|
|
id, project, branch string
|
|
input, output int
|
|
}{
|
|
{"d-a", "alpha", "main", 100, 10},
|
|
{"d-b", "alpha", "feature-x", 200, 20},
|
|
{"d-c", "beta", "main", 300, 30},
|
|
{"d-d", "alpha", "", 400, 40},
|
|
{"d-e", "alpha", "unknown", 500, 50},
|
|
}
|
|
var writes []db.SessionBatchWrite
|
|
for _, s := range seed {
|
|
sess := syncSession(s.id, s.project, s.id+" first", "2026-02-01T12:00:00.000Z", 1)
|
|
sess.GitBranch = s.branch
|
|
writes = append(writes, db.SessionBatchWrite{
|
|
Session: sess,
|
|
// A token-free user message so only the usage event below feeds the
|
|
// usage totals (syncMessage would inject a stray input token).
|
|
Messages: []db.Message{{
|
|
SessionID: s.id,
|
|
Ordinal: 0,
|
|
Role: "user",
|
|
Content: s.id + " first",
|
|
Timestamp: "2026-02-01T12:00:00.000Z",
|
|
ContentLength: len(s.id + " first"),
|
|
}},
|
|
UsageEvents: []db.UsageEvent{{
|
|
Source: "session", Model: "claude-test",
|
|
InputTokens: s.input, OutputTokens: s.output,
|
|
OccurredAt: "2026-02-01T12:01:00.000Z", DedupKey: s.id + "-usage",
|
|
}},
|
|
DataVersion: 1,
|
|
ReplaceMessages: true,
|
|
})
|
|
}
|
|
_, err := local.WriteSessionBatchAtomic(writes)
|
|
require.NoError(t, err)
|
|
|
|
syncer := newInMemoryTestSync(t, local, SyncOptions{})
|
|
_, err = syncer.Push(ctx, true, nil)
|
|
require.NoError(t, err)
|
|
store := NewStoreFromDB(syncer.DB())
|
|
|
|
branches, err := store.GetBranches(ctx, false, false)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []db.BranchInfo{
|
|
{
|
|
Project: "alpha",
|
|
Branch: "",
|
|
Token: db.EncodeBranchFilterToken("alpha", ""),
|
|
},
|
|
{
|
|
Project: "alpha",
|
|
Branch: "feature-x",
|
|
Token: db.EncodeBranchFilterToken("alpha", "feature-x"),
|
|
},
|
|
{
|
|
Project: "alpha",
|
|
Branch: "main",
|
|
Token: db.EncodeBranchFilterToken("alpha", "main"),
|
|
},
|
|
{
|
|
Project: "alpha",
|
|
Branch: "unknown",
|
|
Token: db.EncodeBranchFilterToken("alpha", "unknown"),
|
|
},
|
|
{
|
|
Project: "beta",
|
|
Branch: "main",
|
|
Token: db.EncodeBranchFilterToken("beta", "main"),
|
|
},
|
|
}, branches)
|
|
|
|
filtered, err := store.GetDailyUsage(ctx, db.UsageFilter{
|
|
From: "2026-01-01", To: "2026-12-31",
|
|
GitBranch: db.EncodeBranchFilterToken("alpha", "main"),
|
|
})
|
|
require.NoError(t, err)
|
|
total := 0
|
|
for _, day := range filtered.Daily {
|
|
total += day.InputTokens
|
|
}
|
|
assert.Equal(t, 100, total, "branch filter restricts usage to alpha/main")
|
|
}
|