Skip to content

Commit 0eff947

Browse files
committed
fix(activity): correct usage dedup ordering and zero-cost primary model
SQLite activityReportUsage sorted usage rows by RFC3339 text, so within one second a fractional timestamp ("...00.123Z") sorted before a whole-second one ("...00Z") because '.' < 'Z'. With first-seen-wins dedup this could keep a different duplicate row than PostgreSQL/DuckDB and diverge on token and cost totals. Carry a parsed time.Time on each row and sort on the instant, matching the other backends. primaryAndModels chose a model only when its weight was greater than zero. For usage-only sessions model weight comes from cost, so zero-cost or unpriced usage left primary_model blank while the models list still named the model, showing a known-model session with no model in the table. Fall back to the first model in sorted order when no model has positive weight. Both paths get a regression test: the SQLite usage sort with same-second duplicate keys, and the aggregator with a zero-cost usage-only session.
1 parent 76dd4d5 commit 0eff947

4 files changed

Lines changed: 101 additions & 7 deletions

File tree

internal/activity/activity.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -767,8 +767,11 @@ func minutesOf(s SessionRow) float64 {
767767
return *s.AgentMinutes
768768
}
769769

770-
// primaryAndModels returns the highest-weight model and the sorted set; the
771-
// primary is "" when no model has weight. Caller renders "mixed" when len>1.
770+
// primaryAndModels returns the highest-weight model and the sorted set. When
771+
// no model carries positive weight (e.g. zero-cost or unpriced usage) it falls
772+
// back to the first model in sorted order, so a known-model session still
773+
// reports a primary; the primary is "" only when the set is empty. Caller
774+
// renders "mixed" when len>1.
772775
func primaryAndModels(w map[string]float64) (string, []string) {
773776
var keys []string
774777
primary := ""
@@ -783,5 +786,8 @@ func primaryAndModels(w map[string]float64) (string, []string) {
783786
}
784787
}
785788
sort.Strings(keys)
789+
if primary == "" && len(keys) > 0 {
790+
primary = keys[0]
791+
}
786792
return primary, keys
787793
}

internal/activity/activity_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,3 +511,35 @@ func TestAggregate_BreakdownCostAndAutomatedSegments(t *testing.T) {
511511
assert.InDelta(t, 5.0, r.ByModel[0].AutomatedCost, 1e-9)
512512
assert.InDelta(t, 2.0, r.ByModel[0].InteractiveCost, 1e-9)
513513
}
514+
515+
// TestAggregate_UsageOnlySessionZeroCostKeepsPrimaryModel confirms a session
516+
// whose only signal is zero-cost or unpriced usage still reports its known
517+
// model as the primary. Model weight for usage-only sessions comes from cost,
518+
// so a zero cost left primary_model blank while models listed the model,
519+
// showing a known-model session with no model in the table.
520+
func TestAggregate_UsageOnlySessionZeroCostKeepsPrimaryModel(t *testing.T) {
521+
loc := mustLoad(t, "UTC")
522+
start := mustStart(t, "2026-06-16T00:00:00Z")
523+
end := start.AddDate(0, 0, 1)
524+
p := Params{
525+
RangeStart: start, RangeEnd: end, Loc: loc,
526+
EffectiveEnd: end, Partial: false,
527+
GapCapSeconds: 300, Bucket: BucketSpec{BucketMinute, 300},
528+
}
529+
// One untimed session (no activity events) whose single usage row has a
530+
// known model but ZERO cost.
531+
usage := []UsageRow{
532+
{SessionID: "u", Model: "m1", Timestamp: "2026-06-16T10:00:00Z",
533+
OutputTokens: 0, Cost: 0, ClaudeMessageID: "u", ClaudeRequestID: "r"},
534+
}
535+
sessions := []SessionMeta{
536+
{SessionID: "u", Project: "P", Agent: "claude"},
537+
}
538+
r := Aggregate(p, sessions, nil, usage)
539+
540+
require.Len(t, r.BySession, 1)
541+
row := r.BySession[0]
542+
assert.Equal(t, "m1", row.PrimaryModel,
543+
"zero-cost usage must still report its known model as primary")
544+
assert.Equal(t, []string{"m1"}, row.Models)
545+
}

internal/db/activityreport.go

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,11 @@ func (db *DB) activityReportActivity(
178178
// within the padded range bounds, with per-row cost computed up front
179179
// (mirroring GetDailyUsage) so cost logic stays in the backend. Rows
180180
// are ordered by (ts, session_id, message_ordinal) as the aggregator
181-
// requires for its first-seen-wins dedup.
181+
// requires for its first-seen-wins dedup. The order is computed on the
182+
// parsed instant, not the RFC3339 text, so a whole-second value ("...00Z")
183+
// and a fractional one ("...00.123Z") in the same second sort
184+
// chronologically (matching PostgreSQL/DuckDB); lexically '.' < 'Z' would
185+
// otherwise invert them and let SQLite keep a different duplicate row.
182186
func (db *DB) activityReportUsage(
183187
ctx context.Context, ids []string, lowerBound, upperBound string,
184188
) ([]activity.UsageRow, error) {
@@ -192,13 +196,14 @@ func (db *DB) activityReportUsage(
192196
return nil, fmt.Errorf("loading pricing: %w", err)
193197
}
194198

195-
// Accumulate the per-row dedup ordinal alongside the mapped row so we
196-
// can impose one global (ts, session_id, ordinal) order across all
199+
// Accumulate the parsed ts and dedup ordinal alongside each mapped row so
200+
// we can impose one global (ts, session_id, ordinal) order across all
197201
// chunks. The same (claude_message_id, claude_request_id) can recur in
198202
// different sessions (resumed/forked) and thus different chunks, so
199203
// per-chunk ordering is not enough for the aggregator's first-seen dedup.
200204
type ordered struct {
201205
row activity.UsageRow
206+
ts time.Time
202207
ordinal int64
203208
}
204209
var rowsAcc []ordered
@@ -236,7 +241,9 @@ func (db *DB) activityReportUsage(
236241
if r.messageOrdinal.Valid {
237242
ord = r.messageOrdinal.Int64
238243
}
244+
parsedTS, _ := parseTimestamp(r.ts)
239245
rowsAcc = append(rowsAcc, ordered{
246+
ts: parsedTS,
240247
ordinal: ord,
241248
row: activity.UsageRow{
242249
SessionID: r.sessionID,
@@ -258,8 +265,8 @@ func (db *DB) activityReportUsage(
258265

259266
sort.SliceStable(rowsAcc, func(i, j int) bool {
260267
a, b := rowsAcc[i], rowsAcc[j]
261-
if a.row.Timestamp != b.row.Timestamp {
262-
return a.row.Timestamp < b.row.Timestamp
268+
if !a.ts.Equal(b.ts) {
269+
return a.ts.Before(b.ts)
263270
}
264271
if a.row.SessionID != b.row.SessionID {
265272
return a.row.SessionID < b.row.SessionID

internal/db/activityreport_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,3 +333,52 @@ func TestGetActivityReport_HourlyRange(t *testing.T) {
333333
}
334334
assert.True(t, found, "the 2026-06-17T10:00 hourly bucket must be present")
335335
}
336+
337+
// TestGetActivityReport_UsageDedupSubSecondOrder confirms the SQLite usage
338+
// stream is ordered by the PARSED instant, not the RFC3339 text. A
339+
// resumed/forked pair shares one (claude_message_id, claude_request_id) dedup
340+
// key in the same second: one whole-second instant ("...00Z", 500 output
341+
// tokens) and one fractional ("...00.123Z", 9000). Lexically "...00.123Z"
342+
// sorts before "...00Z" ('.' < 'Z'), so a TEXT sort would keep the 9000 row;
343+
// chronologically the whole-second row is first. First-seen-wins dedup must
344+
// keep the 500 row, matching PostgreSQL/DuckDB which order on the parsed time.
345+
func TestGetActivityReport_UsageDedupSubSecondOrder(t *testing.T) {
346+
d := testDB(t)
347+
ctx := context.Background()
348+
require.NoError(t, d.UpsertModelPricing([]ModelPricing{{
349+
ModelPattern: "claude-sonnet-4-20250514",
350+
InputPerMTok: 3.0,
351+
OutputPerMTok: 15.0,
352+
}}), "UpsertModelPricing")
353+
354+
insertSession(t, d, "earlier", "proj1", func(s *Session) {
355+
s.Agent = "claude"
356+
s.StartedAt = Ptr("2026-06-16T10:30:00Z")
357+
s.EndedAt = Ptr("2026-06-16T10:30:00Z")
358+
})
359+
insertMessages(t, d, Message{
360+
SessionID: "earlier", Ordinal: 0, Role: "assistant", Content: "x",
361+
Timestamp: "2026-06-16T10:30:00Z",
362+
Model: "claude-sonnet-4-20250514",
363+
ClaudeMessageID: "m-dup", ClaudeRequestID: "r-dup",
364+
TokenUsage: json.RawMessage(`{"input_tokens":1000,"output_tokens":500}`),
365+
})
366+
insertSession(t, d, "later", "proj2", func(s *Session) {
367+
s.Agent = "claude"
368+
s.StartedAt = Ptr("2026-06-16T10:30:00Z")
369+
s.EndedAt = Ptr("2026-06-16T10:30:00Z")
370+
})
371+
insertMessages(t, d, Message{
372+
SessionID: "later", Ordinal: 0, Role: "assistant", Content: "x",
373+
Timestamp: "2026-06-16T10:30:00.123Z",
374+
Model: "claude-sonnet-4-20250514",
375+
ClaudeMessageID: "m-dup", ClaudeRequestID: "r-dup",
376+
TokenUsage: json.RawMessage(`{"input_tokens":1000,"output_tokens":9000}`),
377+
})
378+
379+
r, err := d.GetActivityReport(ctx, AnalyticsFilter{Timezone: "UTC"},
380+
dayQuery(t, "2026-06-16", "UTC"))
381+
require.NoError(t, err)
382+
assert.Equal(t, 500, r.Totals.OutputTokens,
383+
"first-seen dedup keeps the chronologically earlier whole-second row")
384+
}

0 commit comments

Comments
 (0)