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
250 changes: 189 additions & 61 deletions docs/features/session-runtime-usage-report-design.md

Large diffs are not rendered by default.

28 changes: 28 additions & 0 deletions src/crates/core/src/agentic/coordination/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -520,10 +520,24 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet
subagent_session_id: None,
status: Some("completed".to_string()),
interruption_reason: None,
queue_wait_ms: None,
preflight_ms: None,
confirmation_wait_ms: None,
execution_ms: Some(outcome.duration_ms),
}],
thinking_items: Vec::new(),
start_time: started_at,
end_time: Some(completed_at),
duration_ms: Some(outcome.duration_ms),
provider_id: None,
model_id: None,
model_alias: None,
first_chunk_ms: None,
first_visible_output_ms: None,
stream_duration_ms: None,
attempt_count: None,
failure_category: None,
token_details: None,
status: "completed".to_string(),
}
}
Expand Down Expand Up @@ -577,10 +591,24 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet
subagent_session_id: None,
status: Some("error".to_string()),
interruption_reason: None,
queue_wait_ms: None,
preflight_ms: None,
confirmation_wait_ms: None,
execution_ms: None,
}],
thinking_items: Vec::new(),
start_time: timestamp,
end_time: Some(timestamp),
duration_ms: Some(0),
provider_id: None,
model_id: None,
model_alias: None,
first_chunk_ms: None,
first_visible_output_ms: None,
stream_duration_ms: None,
attempt_count: None,
failure_category: Some("context_compression".to_string()),
token_details: None,
status: "error".to_string(),
}
}
Expand Down
20 changes: 20 additions & 0 deletions src/crates/core/src/agentic/session/session_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1905,6 +1905,16 @@ impl SessionManager {
thinking_items: Vec::new(),
start_time: completion_timestamp,
end_time: Some(completion_timestamp),
duration_ms: Some(0),
provider_id: None,
model_id: None,
model_alias: None,
first_chunk_ms: None,
first_visible_output_ms: None,
stream_duration_ms: None,
attempt_count: None,
failure_category: None,
token_details: None,
status: "completed".to_string(),
});
}
Expand Down Expand Up @@ -2266,6 +2276,16 @@ impl SessionManager {
thinking_items: vec![],
start_time: now,
end_time: Some(now),
duration_ms: Some(0),
provider_id: None,
model_id: None,
model_alias: None,
first_chunk_ms: None,
first_visible_output_ms: None,
stream_duration_ms: None,
attempt_count: None,
failure_category: None,
token_details: None,
status: "completed".to_string(),
}];

Expand Down
169 changes: 168 additions & 1 deletion src/crates/core/src/service/session/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,62 @@ pub struct ModelRoundData {
pub start_time: u64,
#[serde(skip_serializing_if = "Option::is_none", alias = "end_time")]
pub end_time: Option<u64>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "duration_ms"
)]
pub duration_ms: Option<u64>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "provider_id"
)]
pub provider_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none", alias = "model_id")]
pub model_id: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "model_alias"
)]
pub model_alias: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "first_chunk_ms"
)]
pub first_chunk_ms: Option<u64>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "first_visible_output_ms"
)]
pub first_visible_output_ms: Option<u64>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "stream_duration_ms"
)]
pub stream_duration_ms: Option<u64>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "attempt_count"
)]
pub attempt_count: Option<u32>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "failure_category"
)]
pub failure_category: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "token_details"
)]
pub token_details: Option<serde_json::Value>,
pub status: String,
}

Expand Down Expand Up @@ -372,6 +428,30 @@ pub struct ToolItemData {
pub end_time: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none", alias = "duration_ms")]
pub duration_ms: Option<u64>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "queue_wait_ms"
)]
pub queue_wait_ms: Option<u64>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "preflight_ms"
)]
pub preflight_ms: Option<u64>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "confirmation_wait_ms"
)]
pub confirmation_wait_ms: Option<u64>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "execution_ms"
)]
pub execution_ms: Option<u64>,

/// Original order index (to restore the correct insertion order)
#[serde(skip_serializing_if = "Option::is_none", alias = "order_index")]
Expand Down Expand Up @@ -649,7 +729,10 @@ impl DialogTurnData {

#[cfg(test)]
mod tests {
use super::{DialogTurnData, DialogTurnKind, SessionMetadata, UserMessageData};
use super::{
DialogTurnData, DialogTurnKind, ModelRoundData, SessionMetadata, ToolItemData,
UserMessageData,
};
use crate::agentic::core::SessionKind;

#[test]
Expand Down Expand Up @@ -757,4 +840,88 @@ mod tests {
assert!(!metadata.is_subagent());
assert!(metadata.is_standard());
}

#[test]
fn persisted_runtime_span_fields_are_optional_and_round_trip() {
let legacy_round_payload = serde_json::json!({
"id": "round-legacy",
"turnId": "turn-1",
"roundIndex": 0,
"timestamp": 1,
"textItems": [],
"toolItems": [],
"thinkingItems": [],
"startTime": 1,
"endTime": 2,
"status": "completed"
});

let legacy_round: ModelRoundData =
serde_json::from_value(legacy_round_payload).expect("legacy round should deserialize");
assert_eq!(legacy_round.duration_ms, None);
assert_eq!(legacy_round.model_id, None);
assert_eq!(legacy_round.first_chunk_ms, None);

let round_payload = serde_json::json!({
"id": "round-1",
"turnId": "turn-1",
"roundIndex": 0,
"timestamp": 1,
"textItems": [],
"toolItems": [],
"thinkingItems": [],
"startTime": 1,
"endTime": 121,
"durationMs": 120,
"providerId": "provider-a",
"modelId": "model-a",
"modelAlias": "Model A",
"firstChunkMs": 10,
"firstVisibleOutputMs": 12,
"streamDurationMs": 90,
"attemptCount": 2,
"failureCategory": "rate_limit",
"tokenDetails": { "reasoningTokens": 7 },
"status": "completed"
});

let round: ModelRoundData =
serde_json::from_value(round_payload).expect("P1 round should deserialize");
assert_eq!(round.duration_ms, Some(120));
assert_eq!(round.provider_id.as_deref(), Some("provider-a"));
assert_eq!(round.model_id.as_deref(), Some("model-a"));
assert_eq!(round.first_visible_output_ms, Some(12));
assert_eq!(round.attempt_count, Some(2));
assert_eq!(round.failure_category.as_deref(), Some("rate_limit"));

let encoded = serde_json::to_value(&round).expect("round should serialize");
assert_eq!(encoded["durationMs"], 120);
assert_eq!(encoded["modelId"], "model-a");
assert_eq!(encoded["firstChunkMs"], 10);

let tool_payload = serde_json::json!({
"id": "tool-1",
"toolName": "write_file",
"toolCall": { "id": "call-1", "input": { "file_path": "src/main.rs" } },
"startTime": 5,
"endTime": 105,
"durationMs": 100,
"queueWaitMs": 7,
"preflightMs": 11,
"confirmationWaitMs": 13,
"executionMs": 69,
"status": "completed"
});

let tool: ToolItemData =
serde_json::from_value(tool_payload).expect("P1 tool should deserialize");
assert_eq!(tool.queue_wait_ms, Some(7));
assert_eq!(tool.preflight_ms, Some(11));
assert_eq!(tool.confirmation_wait_ms, Some(13));
assert_eq!(tool.execution_ms, Some(69));

let encoded = serde_json::to_value(&tool).expect("tool should serialize");
assert_eq!(encoded["queueWaitMs"], 7);
assert_eq!(encoded["executionMs"], 69);
}
}
44 changes: 33 additions & 11 deletions src/crates/core/src/service/session_usage/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,19 +191,39 @@ pub fn render_usage_report_markdown(report: &SessionUsageReport) -> String {
));

if !report.models.is_empty() {
let include_duration = report
.models
.iter()
.any(|model| model.duration_ms.is_some());
out.push_str("## Models\n\n");
out.push_str(
"| Model | Calls | Input | Output | Total |\n| --- | ---: | ---: | ---: | ---: |\n",
);
if include_duration {
out.push_str("| Model | Calls | Recorded time | Input | Output | Total |\n| --- | ---: | --- | ---: | ---: | ---: |\n");
} else {
out.push_str(
"| Model | Calls | Input | Output | Total |\n| --- | ---: | ---: | ---: | ---: |\n",
);
}
for model in &report.models {
out.push_str(&format!(
"| {} | {} | {} | {} | {} |\n",
escape_markdown(&model.model_id),
model.call_count,
format_optional_number(model.input_tokens),
format_optional_number(model.output_tokens),
format_optional_number(model.total_tokens)
));
if include_duration {
out.push_str(&format!(
"| {} | {} | {} | {} | {} | {} |\n",
escape_markdown(&model.model_id),
model.call_count,
format_optional_duration(model.duration_ms),
format_optional_number(model.input_tokens),
format_optional_number(model.output_tokens),
format_optional_number(model.total_tokens)
));
} else {
out.push_str(&format!(
"| {} | {} | {} | {} | {} |\n",
escape_markdown(&model.model_id),
model.call_count,
format_optional_number(model.input_tokens),
format_optional_number(model.output_tokens),
format_optional_number(model.total_tokens)
));
}
}
out.push('\n');
}
Expand Down Expand Up @@ -436,6 +456,8 @@ mod tests {
kind: UsageSlowSpanKind::Tool,
duration_ms: 1200,
redacted: true,
turn_id: None,
turn_index: None,
});

let rendered = render_usage_report_markdown(&report);
Expand Down
Loading
Loading