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
87 changes: 84 additions & 3 deletions crates/sl-daemon/src/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ pub struct BundleMeta {
pub token_count: u64,
#[serde(default)]
pub message_count: u64,
/// User-authored turns projected from graph-native intent entities.
#[serde(default)]
pub user_turn_count: u64,
Comment thread
KooshaPari marked this conversation as resolved.
Comment thread
KooshaPari marked this conversation as resolved.
#[serde(default)]
pub duration_ms: u64,
/// Free-form tags array, if present.
Expand Down Expand Up @@ -57,7 +60,16 @@ impl BundleMeta {
let session_id = {
let s = get_str("session_id");
if s.is_empty() {
get_str("id")
let id = get_str("id");
if id.is_empty() {
v.get("source_id")
.or_else(|| v.pointer("/provenance/source_id"))
.and_then(|x| x.as_str())
.unwrap_or_default()
.to_owned()
} else {
id
}
} else {
s
}
Expand Down Expand Up @@ -90,14 +102,34 @@ impl BundleMeta {
}
};
let message_count = get_u64("message_count");
let user_turn_count = v
.get("entities")
.and_then(|entities| entities.as_array())
.map(|entities| {
entities
.iter()
.filter(|entity| entity.get("type").and_then(|kind| kind.as_str()) == Some("intent"))
.filter_map(|entity| entity.pointer("/properties/user_turn_count").and_then(|count| count.as_u64()))
.fold(0u64, |total, count| total.saturating_add(count))
})
.unwrap_or_default();
let duration_ms = get_u64("duration_ms");
let tags = v
.get("tags")
.and_then(|x| x.as_array())
.map(|arr| arr.iter().filter_map(|t| t.as_str().map(str::to_owned)).collect())
.unwrap_or_default();

Self { session_id, created_at, model, token_count, message_count, duration_ms, tags }
Self {
session_id,
created_at,
model,
token_count,
message_count,
user_turn_count,
duration_ms,
tags,
}
}
}

Expand Down Expand Up @@ -131,7 +163,7 @@ impl std::str::FromStr for ExportFormat {
/// commas or quotes are properly quoted per RFC 4180.
pub fn render_csv(metas: &[BundleMeta]) -> String {
let mut out = String::new();
out.push_str("session_id,created_at,model,token_count,message_count,duration_ms,tags\n");
out.push_str("session_id,created_at,model,token_count,message_count,user_turn_count,duration_ms,tags\n");
for m in metas {
let tags = m.tags.join(";");
out.push_str(&csv_field(&m.session_id));
Expand All @@ -144,6 +176,8 @@ pub fn render_csv(metas: &[BundleMeta]) -> String {
out.push(',');
out.push_str(&m.message_count.to_string());
out.push(',');
out.push_str(&m.user_turn_count.to_string());
out.push(',');
out.push_str(&m.duration_ms.to_string());
out.push(',');
out.push_str(&csv_field(&tags));
Expand Down Expand Up @@ -177,6 +211,7 @@ pub fn render_markdown(metas: &[BundleMeta]) -> String {
md_row(&mut out, "model", &m.model);
md_row(&mut out, "token_count", &m.token_count.to_string());
md_row(&mut out, "message_count", &m.message_count.to_string());
md_row(&mut out, "user_turn_count", &m.user_turn_count.to_string());
md_row(&mut out, "duration_ms", &m.duration_ms.to_string());
md_row(&mut out, "tags", &m.tags.join(", "));
out.push('\n');
Expand All @@ -200,6 +235,7 @@ pub fn render_json(metas: &[BundleMeta]) -> String {
"model": m.model,
"token_count": m.token_count,
"message_count": m.message_count,
"user_turn_count": m.user_turn_count,
"duration_ms": m.duration_ms,
"tags": m.tags,
})
Expand Down Expand Up @@ -299,6 +335,7 @@ mod tests {
model: "claude-3-sonnet".into(),
token_count: 1000,
message_count: 10,
user_turn_count: 3,
duration_ms: 500,
tags: vec!["rust".into(), "test".into()],
},
Expand All @@ -308,6 +345,7 @@ mod tests {
model: "claude-3-sonnet".into(),
token_count: 2000,
message_count: 20,
user_turn_count: 1,
duration_ms: 1000,
tags: vec![],
},
Expand Down Expand Up @@ -337,6 +375,8 @@ mod tests {
assert!(out.contains("claude-3-sonnet"));
assert!(out.contains("1000"));
assert!(out.contains("rust;test"));
assert!(out.contains("user_turn_count"));
assert!(out.contains(",3,"));
}

#[test]
Expand Down Expand Up @@ -392,6 +432,7 @@ mod tests {
fn markdown_contains_tags() {
let out = render_markdown(&sample_metas());
assert!(out.contains("rust, test"));
assert!(out.contains("| user_turn_count | 3 |"));
}

#[test]
Expand Down Expand Up @@ -422,6 +463,8 @@ mod tests {
assert!(out.contains("\"session_id\""));
assert!(out.contains("\"token_count\""));
assert!(out.contains("\"tags\""));
assert!(out.contains("\"user_turn_count\""));
assert!(out.contains("\"user_turn_count\": 3"));
}

#[test]
Expand Down Expand Up @@ -513,4 +556,42 @@ mod tests {
let m = BundleMeta::from_value(&v);
assert_eq!(m.token_count, 999);
}

#[test]
fn from_value_projects_graph_okf_identity_and_user_turns() {
let v = serde_json::json!({
"okf": "1.0",
"source_id": "codex-session-1",
"entities": [
{
"id": "intent-0",
"type": "intent",
"label": "ship the metadata contract",
"properties": { "user_turn_count": 3 }
}
],
"relations": [],
"provenance": { "corpus": "codex", "source_id": "codex-session-1" }
});

let m = BundleMeta::from_value(&v);
assert_eq!(m.session_id, "codex-session-1");
assert_eq!(m.user_turn_count, 3);
assert_eq!(m.message_count, 0);
assert!(m.model.is_empty());
assert!(m.created_at.is_empty());
assert_eq!(m.token_count, 0);
}

#[test]
fn from_value_saturates_graph_user_turn_counts() {
let v = serde_json::json!({
"entities": [
{ "type": "intent", "properties": { "user_turn_count": u64::MAX } },
{ "type": "intent", "properties": { "user_turn_count": 1 } }
]
});

assert_eq!(BundleMeta::from_value(&v).user_turn_count, u64::MAX);
}
}
1 change: 1 addition & 0 deletions crates/sl-daemon/src/filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ mod tests {
model: model.into(),
token_count,
message_count: 1,
user_turn_count: 0,
duration_ms: 0,
tags: tags.iter().map(|s| s.to_string()).collect(),
}
Expand Down
28 changes: 28 additions & 0 deletions crates/sl-daemon/src/metrics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ use std::sync::RwLock;
use serde::{Deserialize, Serialize};
use tracing::info;

use crate::export::BundleMeta;

/// Prometheus histogram bucket upper bounds in seconds.
pub const HTTP_DURATION_BUCKETS: &[f64] =
&[0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0];
Expand Down Expand Up @@ -240,6 +242,8 @@ fn linux_open_fds() -> Option<u64> {
pub struct MetricsSummary {
pub total_bundles: u64,
pub total_tokens: u64,
#[serde(default)]
pub total_user_turns: u64,
Comment thread
KooshaPari marked this conversation as resolved.
pub avg_tokens: u64,
pub model_counts: HashMap<String, u64>,
pub daily_counts: HashMap<String, u64>,
Expand All @@ -257,6 +261,7 @@ pub fn compute_metrics(out_dir: &Path) -> MetricsSummary {
let Ok(content) = std::fs::read_to_string(&path) else { continue };
let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) else { continue };
s.total_bundles += 1;
s.total_user_turns = s.total_user_turns.saturating_add(BundleMeta::from_value(&val).user_turn_count);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: compute_metrics now rebuilds a full BundleMeta via BundleMeta::from_value(&val) for every bundle file just to read one nested field. This re-parses the entire document (session id, tags, created_at, etc.) on each metrics run and re-derives all defaults. Consider extracting the user-turn count directly from val["entities"] with the same entities/pointer logic so the per-bundle cost stays flat and you avoid the redundant full parse. This also decouples metrics from unrelated BundleMeta parsing changes in the future.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

if let Some(t) = val.get("total_tokens").and_then(|v| v.as_u64()) {
s.total_tokens += t;
}
Expand Down Expand Up @@ -358,6 +363,29 @@ mod tests {
assert_eq!(m.total_tokens, 0);
}

#[test]
fn graph_okf_contributes_user_turns_without_fabricating_tokens() {
let d = tempfile::TempDir::new().unwrap();
std::fs::write(
d.path().join("graph.okf.json"),
r#"{"okf":"1.0","source_id":"graph-1","entities":[{"id":"intent-0","type":"intent","label":"ship it","properties":{"user_turn_count":4}}],"relations":[],"provenance":{"corpus":"codex","source_id":"graph-1"}}"#,
)
.unwrap();

let m = compute_metrics(d.path());
assert_eq!(m.total_bundles, 1);
assert_eq!(m.total_user_turns, 4);
assert_eq!(m.total_tokens, 0);
}

#[test]
fn metrics_deserializes_legacy_payload_without_user_turns() {
let legacy = r#"{"total_bundles":1,"total_tokens":42,"avg_tokens":42,"model_counts":{},"daily_counts":{}}"#;

let metrics: MetricsSummary = serde_json::from_str(legacy).expect("legacy metrics payload");
assert_eq!(metrics.total_user_turns, 0);
}

#[test]
fn normalize_http_route_collapses_replay_paths() {
assert_eq!(normalize_http_route("/healthz"), "healthz");
Expand Down
20 changes: 20 additions & 0 deletions crates/sl-viewer/src/search_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ pub struct SearchResult {
#[serde(default)]
pub message_count: u64,
#[serde(default)]
pub user_turn_count: u64,
#[serde(default)]
pub duration_ms: u64,
#[serde(default)]
pub tags: Vec<String>,
Expand Down Expand Up @@ -86,6 +88,14 @@ fn urlencoding(s: &str) -> String {
.collect()
}

fn user_turn_label(user_turn_count: u64) -> &'static str {
if user_turn_count == 1 {
"user turn"
} else {
"user turns"
}
}

/// Stable id for search fetch errors — paired with `aria-errormessage` on fields.
const SEARCH_ERROR_ID: &str = "search-error-message";

Expand Down Expand Up @@ -459,6 +469,7 @@ pub fn SearchView() -> Element {
let cls = if is_selected { "session-item selected" } else { "session-item" };
let r = result.clone();
let tags_display = r.tags.join(", ");
let user_turn_label = user_turn_label(r.user_turn_count);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: The local let user_turn_label = user_turn_label(r.user_turn_count); shadows the user_turn_label helper function defined just above. This is legal Rust but harms readability and can trip clippy's shadow lints (clippy::shadow_reuse / shadow_unrelated) under the repo's strict zero-warning config. Rename the local (e.g. let turn_label = user_turn_label(r.user_turn_count);) and update the {user_turn_label} interpolation on the rendered span accordingly.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

rsx! {
div {
key: "{r.session_id}-{idx}",
Expand All @@ -468,6 +479,9 @@ pub fn SearchView() -> Element {
div { class: "session-goal", "model: {r.model}" }
div { class: "session-meta",
span { class: "meta-bundles", "{r.token_count} tokens" }
if r.user_turn_count > 0 {
span { class: "session-meta-muted", "{r.user_turn_count} {user_turn_label}" }
}
span { class: "session-meta-muted", "{r.created_at}" }
if !tags_display.is_empty() {
span { class: "badge badge-ok", "{tags_display}" }
Expand Down Expand Up @@ -609,4 +623,10 @@ mod tests {
assert_eq!(advanced_filter_active_count("", "rust", "50"), 1);
assert_eq!(advanced_filter_active_count(" ", " ", "50"), 0);
}

#[test]
fn user_turn_label_handles_singular_and_plural() {
assert_eq!(user_turn_label(1), "user turn");
assert_eq!(user_turn_label(2), "user turns");
}
}
10 changes: 8 additions & 2 deletions crates/sl-viewer/tests/properties_viewer_timeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ proptest! {
/// `MAX_PX` (the bar scale is anchored at the maximum).
#[test]
fn normalize_widths_max_token_renders_max_px(
// First entry: heavy. Rest: light.
// First entry has an arbitrary count. Rest may be heavier.
heavy_tokens in 1u64..1_000_000,
light_tokens in 0u64..1000,
rest in 0usize..6,
Expand Down Expand Up @@ -275,7 +275,13 @@ proptest! {
});
}
let widths = normalize_widths(&entries);
prop_assert_eq!(widths[0], MAX_PX, "the heavy entry must render at MAX_PX");
prop_assert_eq!(entries.len(), widths.len());
let max_tokens = entries.iter().map(|entry| entry.token_count).max().unwrap_or_default();
for (entry, width) in entries.iter().zip(widths) {
Comment thread
KooshaPari marked this conversation as resolved.
if entry.token_count == max_tokens {
prop_assert_eq!(width, MAX_PX, "a maximum entry must render at MAX_PX");
}
}
}
}

Expand Down
Loading