Skip to main content

systemprompt_cli/presentation/tables/
trace.rs

1//! Task-execution tables: task summary, steps, AI requests, MCP tool calls,
2//! artifacts, and the interleaved trace view.
3//!
4//! Copyright (c) systemprompt.io — Business Source License 1.1.
5//! See <https://systemprompt.io> for licensing details.
6
7use systemprompt_identifiers::{AgentName, McpServerId, McpToolName};
8use systemprompt_runtime::{
9    AiRequestInfo, ExecutionStep, McpToolExecution, TaskArtifact, TaskInfo, TraceEvent,
10};
11use tabled::settings::Style;
12use tabled::{Table, Tabled};
13
14use super::{OrDash, dash, millis, truncate_cell};
15
16#[derive(Tabled)]
17struct TaskInfoRow {
18    #[tabled(rename = "Task ID")]
19    task: String,
20    #[tabled(rename = "Agent")]
21    agent_name: OrDash<AgentName>,
22    #[tabled(rename = "Status")]
23    status: String,
24    #[tabled(rename = "Started")]
25    started_at: String,
26    #[tabled(rename = "Duration")]
27    duration: String,
28}
29
30#[must_use]
31pub fn task_info_table(task_info: &TaskInfo) -> String {
32    let rows = vec![TaskInfoRow {
33        task: task_info.task_id.as_str().chars().take(8).collect(),
34        agent_name: OrDash(task_info.agent_name.clone()),
35        status: task_info.status.clone(),
36        started_at: task_info
37            .started_at
38            .map_or_else(dash, |t| t.format("%H:%M:%S").to_string()),
39        duration: millis(task_info.execution_time_ms),
40    }];
41    Table::new(rows).with(Style::rounded()).to_string()
42}
43
44#[derive(Tabled)]
45struct StepRow {
46    #[tabled(rename = "#")]
47    step_number: usize,
48    #[tabled(rename = "Type")]
49    step_type: String,
50    #[tabled(rename = "Title")]
51    title: String,
52    #[tabled(rename = "Status")]
53    status: String,
54    #[tabled(rename = "Duration")]
55    duration: String,
56}
57
58#[must_use]
59pub fn execution_steps_table(steps: &[ExecutionStep]) -> String {
60    let rows: Vec<StepRow> = steps
61        .iter()
62        .enumerate()
63        .map(|(i, s)| StepRow {
64            step_number: i + 1,
65            step_type: s.step_type.clone().unwrap_or_else(|| "unknown".to_owned()),
66            title: truncate_cell(s.title.as_deref().unwrap_or_default(), 40),
67            status: s.status.clone(),
68            duration: millis(s.duration_ms),
69        })
70        .collect();
71    Table::new(rows).with(Style::rounded()).to_string()
72}
73
74#[derive(Tabled)]
75struct AiRequestRow {
76    #[tabled(rename = "Model")]
77    model: String,
78    #[tabled(rename = "Max")]
79    max_tokens: String,
80    #[tabled(rename = "Tokens")]
81    tokens: String,
82    #[tabled(rename = "Cost")]
83    cost: String,
84    #[tabled(rename = "Latency")]
85    latency: String,
86}
87
88#[must_use]
89pub fn ai_requests_table(requests: &[AiRequestInfo]) -> String {
90    let rows: Vec<AiRequestRow> = requests
91        .iter()
92        .map(|r| AiRequestRow {
93            model: format!(
94                "{}/{}",
95                r.provider.as_deref().unwrap_or("-"),
96                r.model.as_deref().unwrap_or("-")
97            ),
98            max_tokens: r.max_tokens.map_or_else(dash, |t| t.to_string()),
99            tokens: format!(
100                "{} (in:{}, out:{})",
101                r.input_tokens.unwrap_or(0) + r.output_tokens.unwrap_or(0),
102                r.input_tokens.unwrap_or(0),
103                r.output_tokens.unwrap_or(0)
104            ),
105            #[expect(
106                clippy::cast_precision_loss,
107                reason = "display-only dollar conversion of microdollar totals"
108            )]
109            cost: format!("${:.4}", r.cost_microdollars as f64 / 1_000_000.0),
110            latency: millis(r.latency_ms),
111        })
112        .collect();
113    Table::new(rows).with(Style::rounded()).to_string()
114}
115
116#[derive(Tabled)]
117struct ToolCallRow {
118    #[tabled(rename = "Tool")]
119    tool_name: McpToolName,
120    #[tabled(rename = "Server")]
121    server: McpServerId,
122    #[tabled(rename = "Status")]
123    status: String,
124    #[tabled(rename = "Duration")]
125    duration: String,
126}
127
128#[must_use]
129pub fn mcp_tool_calls_table(executions: &[McpToolExecution]) -> String {
130    let rows: Vec<ToolCallRow> = executions
131        .iter()
132        .map(|e| ToolCallRow {
133            tool_name: e.tool_name.clone(),
134            server: e.server_name.clone(),
135            status: e.status.clone(),
136            duration: millis(e.execution_time_ms),
137        })
138        .collect();
139    Table::new(rows).with(Style::rounded()).to_string()
140}
141
142#[derive(Tabled)]
143struct TaskArtifactRow {
144    #[tabled(rename = "ID")]
145    artifact: String,
146    #[tabled(rename = "Type")]
147    artifact_type: String,
148    #[tabled(rename = "Name")]
149    name: String,
150    #[tabled(rename = "Source")]
151    source: String,
152    #[tabled(rename = "Tool")]
153    tool_name: OrDash<McpToolName>,
154}
155
156#[must_use]
157pub fn task_artifacts_table(artifacts: &[TaskArtifact]) -> String {
158    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
159    let rows: Vec<TaskArtifactRow> = artifacts
160        .iter()
161        .filter(|a| seen.insert(a.artifact_id.to_string()))
162        .map(|a| TaskArtifactRow {
163            artifact: truncate_cell(a.artifact_id.as_str(), 12),
164            artifact_type: a.artifact_type.clone(),
165            name: a.name.as_ref().map_or_else(dash, |s| truncate_cell(s, 30)),
166            source: a.source.clone().unwrap_or_else(dash),
167            tool_name: OrDash(a.tool_name.clone()),
168        })
169        .collect();
170    Table::new(&rows).with(Style::rounded()).to_string()
171}
172
173#[derive(Tabled)]
174struct TraceRow {
175    #[tabled(rename = "Time")]
176    time: String,
177    #[tabled(rename = "Delta")]
178    delta: String,
179    #[tabled(rename = "Type")]
180    event_type: String,
181    #[tabled(rename = "Details")]
182    details: String,
183    #[tabled(rename = "Latency")]
184    latency: String,
185}
186
187#[must_use]
188// JSON: trace event `metadata` value — open-shaped JSONB, formatted per key.
189pub fn format_metadata_value(key: &str, value: &serde_json::Value) -> String {
190    let raw = || format!("{value}").trim_matches('"').to_owned();
191    match key {
192        "cost_microdollars" => value.as_i64().map_or_else(raw, |microdollars| {
193            #[expect(
194                clippy::cast_precision_loss,
195                reason = "display-only dollar conversion of microdollar totals"
196            )]
197            let dollars = microdollars as f64 / 1_000_000.0;
198            format!("${dollars:.6}")
199        }),
200        "latency_ms" | "execution_time_ms" => {
201            value.as_i64().map_or_else(raw, |ms| format!("{ms}ms"))
202        },
203        "tokens_used" => value.as_i64().map_or_else(raw, |tokens| tokens.to_string()),
204        _ => raw(),
205    }
206}
207
208#[must_use]
209pub fn extract_latency_from_metadata(metadata: Option<&str>, event_type: &str) -> String {
210    if let Some(meta) = metadata
211        && let Ok(parsed) = serde_json::from_str::<serde_json::Value>(meta)
212    {
213        let key = match event_type {
214            "AI" => Some("latency_ms"),
215            "MCP" => Some("execution_time_ms"),
216            _ => None,
217        };
218        if let Some(key) = key
219            && let Some(ms) = parsed.get(key).and_then(serde_json::Value::as_i64)
220        {
221            return format!("{ms}ms");
222        }
223    }
224    dash()
225}
226
227#[must_use]
228pub fn trace_events_table(events: &[TraceEvent]) -> String {
229    let mut prev_timestamp: Option<chrono::DateTime<chrono::Utc>> = None;
230    let rows: Vec<TraceRow> = events
231        .iter()
232        .map(|e| {
233            let delta = prev_timestamp.map_or_else(
234                || "+0ms".to_owned(),
235                |prev| {
236                    let delta_ms = e.timestamp.signed_duration_since(prev).num_milliseconds();
237                    format!("+{delta_ms}ms")
238                },
239            );
240            prev_timestamp = Some(e.timestamp);
241            TraceRow {
242                time: e.timestamp.format("%H:%M:%S%.3f").to_string(),
243                delta,
244                event_type: e.event_type.clone(),
245                details: truncate_cell(&e.details, 100),
246                latency: extract_latency_from_metadata(e.metadata.as_deref(), &e.event_type),
247            }
248        })
249        .collect();
250
251    if rows.is_empty() {
252        String::new()
253    } else {
254        Table::new(rows).with(Style::modern()).to_string()
255    }
256}