Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ const overrides = new Map([
// command's SQLite core end to end. Test-only content; ratcheted
// 1208 -> 1424.
["src-tauri/src/archive/mod_tests.rs", 1424],
// agent-usage-harness: M1 migration (atomic transaction wrapper, PRAGMA
// guard, canonical-store worklist) + rebuild_metric_index_in_tx helper
// + apply_schema_migrations + store_migration_tests.rs #[path] include.
// Load-bearing correctness fix (Thufir IMPORTANT). M1 tests split out.
["src-tauri/src/archive/store.rs", 1044],
// unified-agent-model 1A.1: profile reconcile split to agents_profile.rs,
// ratcheting 1443 -> 1295. Queued to split further in the A2 fold.
// global-agent-config: resolve_deploy_model_provider + visibility exports
Expand Down
79 changes: 57 additions & 22 deletions desktop/src-tauri/src/archive/agent_usage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ pub struct SeriesBucket {
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelUsage {
pub harness: Option<String>,
pub model: Option<String>,
pub usage: ReportedUsage,
pub report_count: i64,
Expand Down Expand Up @@ -456,6 +457,32 @@ fn assign_bucket_index(boundaries: &[i64], t: i64) -> Option<usize> {
(0..boundaries.len().saturating_sub(1)).find(|&i| t >= boundaries[i] && t < boundaries[i + 1])
}

// ── Sort helpers ─────────────────────────────────────────────────────────────

/// Compare two known-total values for the A2 ranking rule: known totals rank
/// before unknown totals (None); within known totals, descending; within
/// unknown totals, equal (callers chain further tiebreaks).
fn cmp_known_total(a: Option<u64>, b: Option<u64>) -> std::cmp::Ordering {
match (a, b) {
(Some(av), Some(bv)) => bv.cmp(&av), // descending
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
}
}

/// Ordinal (byte-order) comparison of two optional strings, with `None`
/// sorting after any `Some` value — used as a stable tiebreak for harness and
/// model names so ordering is locale-independent and matches the Rust backend.
fn cmp_option_str_none_last(a: &Option<String>, b: &Option<String>) -> std::cmp::Ordering {
match (a, b) {
(Some(av), Some(bv)) => av.cmp(bv),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
}
}

// ── compute_series ───────────────────────────────────────────────────────────

/// Per-agent accumulation scope, built while walking `window_rows` once.
Expand All @@ -464,7 +491,8 @@ struct AgentScope {
bucket_counts: Vec<i64>,
total: UsageAccumulator,
report_count: i64,
models: HashMap<Option<String>, (UsageAccumulator, i64)>,
/// Keyed by `(harness, model)` — same model via two harnesses → two rows.
models: HashMap<(Option<String>, Option<String>), (UsageAccumulator, i64)>,
}

/// Compute the full [`AgentUsageSeries`] from already-loaded rows.
Expand Down Expand Up @@ -551,7 +579,7 @@ pub(super) fn compute_series(

let model_entry = scope
.models
.entry(row.model.clone())
.entry((row.harness.clone(), row.model.clone()))
.or_insert_with(|| (UsageAccumulator::default(), 0i64));
model_entry.0.add(&outcome);
model_entry.1 += 1;
Expand Down Expand Up @@ -602,31 +630,43 @@ pub(super) fn compute_series(
})
.collect();

let mut model_rows: Vec<(Option<u64>, Option<String>, ModelUsage)> = scope
// Named sort key so the 4-tuple doesn't exceed clippy's type_complexity
// threshold and the ordering intent reads at a glance.
struct ModelSortKey {
total: Option<u64>,
harness: Option<String>,
model: Option<String>,
usage: ModelUsage,
}
let mut model_rows: Vec<ModelSortKey> = scope
.models
.into_iter()
.map(|(model, (acc, count))| {
let model_total = acc.total_tokens_value();
.map(|((harness, model), (acc, count))| {
let total = acc.total_tokens_value();
let has_unknown_usage = acc.has_unknown();
(
model_total,
model.clone(),
ModelUsage {
ModelSortKey {
total,
harness: harness.clone(),
model: model.clone(),
usage: ModelUsage {
harness,
model,
usage: acc.finish(),
report_count: count,
has_unknown_usage,
},
)
}
})
.collect();
model_rows.sort_by(|a, b| match (a.0, b.0) {
(Some(av), Some(bv)) => bv.cmp(&av).then_with(|| a.1.cmp(&b.1)),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => a.1.cmp(&b.1),
// A2 ranking: known totalTokens descending, unknown-total after;
// tiebreak: harness ascending (None last), then model ascending
// (None last), for deterministic ordering.
model_rows.sort_by(|a, b| {
cmp_known_total(a.total, b.total)
.then_with(|| cmp_option_str_none_last(&a.harness, &b.harness))
.then_with(|| cmp_option_str_none_last(&a.model, &b.model))
});
let models = model_rows.into_iter().map(|(_, _, m)| m).collect();
let models = model_rows.into_iter().map(|k| k.usage).collect();

(
total_tokens_value,
Expand All @@ -642,12 +682,7 @@ pub(super) fn compute_series(
)
})
.collect();
agent_rows.sort_by(|a, b| match (a.0, b.0) {
(Some(av), Some(bv)) => bv.cmp(&av).then_with(|| a.1.cmp(&b.1)),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => a.1.cmp(&b.1),
});
agent_rows.sort_by(|a, b| cmp_known_total(a.0, b.0).then_with(|| a.1.cmp(&b.1)));
let any_agent_unknown = agent_rows.iter().any(|(_, _, a)| a.has_unknown_usage);
let agents: Vec<AgentUsage> = agent_rows.into_iter().map(|(_, _, a)| a).collect();

Expand Down
68 changes: 68 additions & 0 deletions desktop/src-tauri/src/archive/agent_usage_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ fn row(id: &str, agent: &str, session: &str, seq: u64, reported_at: i64) -> Agen
reported_at: Some(reported_at),
session_id: Some(session.to_string()),
turn_seq: Some(seq),
harness: None,
model: None,
delta_reliable: Some(true),
turn_input_tokens: None,
Expand Down Expand Up @@ -590,6 +591,73 @@ fn compute_series_model_breakdown_attributes_per_event_model() {
assert_eq!(series.agents[0].models.len(), 2);
}

#[test]
fn compute_series_same_model_two_harnesses_produces_two_rows() {
// Collapse-fix requirement: same model via two different harnesses must
// NOT collapse into one row — (harness, model) is the grouping key.
let r1 = AgentMetricIndexRow {
harness: Some("goose".to_string()),
model: Some("claude-sonnet".to_string()),
turn_input_tokens: Some(100),
delta_reliable: Some(true),
..row("e1", "agent1", "s1", 1, 0)
};
let r2 = AgentMetricIndexRow {
harness: Some("claude-code".to_string()),
model: Some("claude-sonnet".to_string()),
turn_input_tokens: Some(200),
delta_reliable: Some(true),
..row("e2", "agent1", "s2", 1, 1)
};
let boundaries = boundaries_7();
let rows = vec![r1, r2];
let series = compute_series(&rows, &rows, 0, &boundaries, None, true);
assert_eq!(series.agents.len(), 1);
// Two distinct harnesses → two rows, not one collapsed row.
assert_eq!(
series.agents[0].models.len(),
2,
"same model under two harnesses must produce two rows"
);
let harneses: Vec<Option<&str>> = series.agents[0]
.models
.iter()
.map(|m| m.harness.as_deref())
.collect();
assert!(
harneses.contains(&Some("claude-code")),
"claude-code row must be present"
);
assert!(
harneses.contains(&Some("goose")),
"goose row must be present"
);
}

#[test]
fn compute_series_single_harness_data_looks_unchanged_with_harness_label() {
// Single-harness data: still exactly one row per model, harness label present.
let r = AgentMetricIndexRow {
harness: Some("goose".to_string()),
model: Some("claude-sonnet".to_string()),
turn_total_tokens: Some(500),
delta_reliable: Some(true),
..row("e1", "agent1", "s1", 1, 0)
};
let boundaries = boundaries_7();
let rows = vec![r];
let series = compute_series(&rows, &rows, 0, &boundaries, None, true);
assert_eq!(series.agents[0].models.len(), 1);
assert_eq!(
series.agents[0].models[0].harness,
Some("goose".to_string())
);
assert_eq!(
series.agents[0].models[0].model,
Some("claude-sonnet".to_string())
);
}

#[test]
fn compute_series_invalid_report_count_passed_through_and_not_bucketed() {
let boundaries = boundaries_7();
Expand Down
11 changes: 8 additions & 3 deletions desktop/src-tauri/src/archive/metric_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ pub(super) struct AgentMetricIndexRow {
pub reported_at: Option<i64>,
pub session_id: Option<String>,
pub turn_seq: Option<u64>,
pub harness: Option<String>,
pub model: Option<String>,
pub delta_reliable: Option<bool>,
pub turn_input_tokens: Option<u64>,
Expand Down Expand Up @@ -141,6 +142,7 @@ impl AgentMetricIndexRow {
reported_at,
session_id: payload.session_id,
turn_seq: payload.turn_seq,
harness: Some(payload.harness),
model: payload.model,
delta_reliable: Some(payload.delta_reliable),
turn_input_tokens: turn.and_then(|t| t.input_tokens),
Expand All @@ -164,6 +166,7 @@ impl AgentMetricIndexRow {
reported_at: None,
session_id: None,
turn_seq: None,
harness: None,
model: None,
delta_reliable: None,
turn_input_tokens: None,
Expand Down Expand Up @@ -208,6 +211,7 @@ fn row_from_sql(row: &rusqlite::Row) -> rusqlite::Result<AgentMetricIndexRow> {
reported_at: row.get("reported_at")?,
session_id: row.get("session_id")?,
turn_seq: turn_seq_text.as_deref().and_then(decode_u64_sortable),
harness: row.get("harness")?,
model: row.get("model")?,
delta_reliable: delta_reliable_int.map(|v| v != 0),
turn_input_tokens: turn_input_text.as_deref().and_then(decode_u64_sortable),
Expand All @@ -223,7 +227,7 @@ fn row_from_sql(row: &rusqlite::Row) -> rusqlite::Result<AgentMetricIndexRow> {
}

const ROW_COLUMNS: &str = "id, agent_pubkey, event_created_at, archived_at, reported_at, \
session_id, turn_seq, model, delta_reliable, turn_input_tokens, turn_output_tokens, \
session_id, turn_seq, harness, model, delta_reliable, turn_input_tokens, turn_output_tokens, \
turn_total_tokens, turn_cost_usd, cumulative_input_tokens, cumulative_output_tokens, \
cumulative_total_tokens, cumulative_cost_usd, parse_status";

Expand Down Expand Up @@ -256,13 +260,13 @@ pub(super) fn insert_metric_index_row(
.execute(
"INSERT INTO agent_metric_index
(identity_pubkey, relay_url, id, agent_pubkey, event_created_at,
archived_at, reported_at, session_id, turn_seq, model,
archived_at, reported_at, session_id, turn_seq, harness, model,
delta_reliable, turn_input_tokens, turn_output_tokens,
turn_total_tokens, turn_cost_usd, cumulative_input_tokens,
cumulative_output_tokens, cumulative_total_tokens,
cumulative_cost_usd, parse_status)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10,
?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)
?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21)
ON CONFLICT (identity_pubkey, relay_url, id) DO NOTHING",
params![
identity_pubkey,
Expand All @@ -274,6 +278,7 @@ pub(super) fn insert_metric_index_row(
row.reported_at,
row.session_id,
turn_seq,
row.harness,
row.model,
delta_reliable,
turn_input,
Expand Down
74 changes: 74 additions & 0 deletions desktop/src-tauri/src/archive/metric_store_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,15 @@ fn from_payload_parses_valid_row() {
assert_eq!(row.turn_input_tokens, Some(10));
assert_eq!(row.cumulative_input_tokens, Some(100));
assert_eq!(row.model, Some("claude".to_string()));
assert_eq!(row.harness, Some("goose".to_string()));
}

#[test]
fn from_payload_parses_harness_field() {
let json = r#"{"harness":"claude-code","model":"claude-sonnet","timestamp":"2026-07-01T20:11:03Z","turn":{"inputTokens":5,"outputTokens":null,"totalTokens":null,"costUsd":null}}"#;
let row = AgentMetricIndexRow::from_payload(json, "eid1", "agent1", 100, 200);
assert_eq!(row.parse_status, ParseStatus::Valid);
assert_eq!(row.harness, Some("claude-code".to_string()));
}

#[test]
Expand Down Expand Up @@ -577,3 +586,68 @@ fn predecessor_lookup_uses_session_index() {
"predecessor lookup must use the session index, plan was:\n{plan}"
);
}

// ── Migration: old-shape rows get harness populated on rebuild ────────────────

/// Simulate a pre-harness archive row by inserting an archived_event and an
/// index row with harness = NULL (as the old schema would have stored it),
/// then running the backfill rebuild path to verify harness is populated.
#[test]
fn migration_old_shape_rows_get_harness_populated_after_rebuild() {
let conn = in_memory();
let json = valid_payload_json("s1", 1, "2026-07-01T00:00:00Z");
// Seed an archived event.
insert_archived_event(
&conn, "id", "relay", "eid1", 44200, "agent1", 100, &json, 200,
);
// Seed an index row with harness explicitly NULL (simulates pre-migration row).
conn.execute(
"INSERT INTO agent_metric_index
(identity_pubkey, relay_url, id, agent_pubkey, event_created_at,
archived_at, reported_at, session_id, turn_seq, harness, model,
delta_reliable, turn_input_tokens, turn_output_tokens,
turn_total_tokens, turn_cost_usd, cumulative_input_tokens,
cumulative_output_tokens, cumulative_total_tokens,
cumulative_cost_usd, parse_status)
VALUES ('id','relay','eid1','agent1',100,200,1751414400000,
's1','00000000000000000001',NULL,'claude',
1,'00000000000000000010','00000000000000000020',
'00000000000000000030',0.01,
'00000000000000000100','00000000000000000200',
'00000000000000000300',0.1,'valid')",
[],
)
.unwrap();

// Verify the row is there with NULL harness.
let harness_before: Option<String> = conn
.query_row(
"SELECT harness FROM agent_metric_index WHERE id = 'eid1'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(
harness_before, None,
"old-shape row should start with NULL harness"
);

// Simulate the migration: delete index rows and re-run backfill.
conn.execute_batch("DELETE FROM agent_metric_index")
.unwrap();
backfill_agent_metric_index(&conn, "id", "relay").unwrap();

// The rebuilt row must have harness populated.
let harness_after: Option<String> = conn
.query_row(
"SELECT harness FROM agent_metric_index WHERE id = 'eid1'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(
harness_after,
Some("goose".to_string()),
"rebuilt row must have harness populated from raw_json"
);
}
Loading
Loading