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
89 changes: 17 additions & 72 deletions crates/agtrace-cli/src/handlers/session_show.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ use crate::args::{OutputFormat, ViewModeArgs};
use crate::handlers::HandlerContext;
use crate::presentation::presenters;
use agtrace_sdk::Client;
use agtrace_sdk::types::StreamId;
use anyhow::{Context, Result};

pub fn handle(
Expand All @@ -23,18 +22,10 @@ pub fn handle(
let children = session_handle.child_sessions().unwrap_or_default();

// Use assemble_all() to get all streams (Main + Sidechain + Subagent)
let mut sessions = session_handle
let sessions = session_handle
.assemble_all()
.with_context(|| format!("Failed to assemble session: {}", session_id))?;

// Sort: Main first, then others by stream_id string
sessions.sort_by(|a, b| match (&a.stream_id, &b.stream_id) {
(StreamId::Main, StreamId::Main) => std::cmp::Ordering::Equal,
(StreamId::Main, _) => std::cmp::Ordering::Less,
(_, StreamId::Main) => std::cmp::Ordering::Greater,
(a_id, b_id) => a_id.as_str().cmp(&b_id.as_str()),
});

let log_files: Vec<String> = session_handle
.raw_files()?
.into_iter()
Expand All @@ -50,66 +41,20 @@ pub fn handle(
.get_limit(&model_name_key)
.map(|spec| spec.effective_limit() as u32);

// Format spawn info from metadata (for Codex subagent sessions with separate files)
let metadata_spawn_info = metadata
.spawned_by
.as_ref()
.map(|ctx| {
format!(
" (spawned by Turn #{}, Step #{})",
ctx.turn_index + 1,
ctx.step_index + 1
)
})
.unwrap_or_default();

// Present each stream
for (idx, session) in sessions.iter().enumerate() {
if idx > 0 {
// Add separator between streams with spawn context (for Claude Code sidechains)
println!("\n{}", "─".repeat(80));
let spawn_info = session
.spawned_by
.as_ref()
.map(|ctx| {
format!(
" (spawned by Turn #{}, Step #{})",
ctx.turn_index + 1,
ctx.step_index + 1
)
})
.unwrap_or_default();
println!(
"Additional Stream: {}{}\n",
session.stream_id.as_str(),
spawn_info
);
}

// Print spawn context for the main stream if it comes from DB metadata (Codex subagents)
if idx == 0 && !metadata_spawn_info.is_empty() {
println!("Spawned:{}", metadata_spawn_info);
println!();
}

// Only pass children for main stream
let stream_children: &[_] = if idx == 0 { &children } else { &[] };

let view_model = presenters::present_session_analysis(
session,
&metadata.session_id,
&metadata.provider,
metadata.project_hash.as_ref(),
metadata.project_root.as_deref(),
&model_name_display,
max_context,
// Only show log_files for the first (main) stream
if idx == 0 { log_files.clone() } else { vec![] },
stream_children,
);

ctx.render(view_model)?;
}

Ok(())
// Present the whole session (all streams) as a single view model so that
// every output format emits exactly one document.
let view_model = presenters::present_session_detail(
&sessions,
&metadata.session_id,
&metadata.provider,
metadata.project_hash.as_ref(),
metadata.project_root.as_deref(),
metadata.spawned_by.as_ref(),
&model_name_display,
max_context,
log_files,
&children,
);

ctx.render(view_model)
}
2 changes: 1 addition & 1 deletion crates/agtrace-cli/src/presentation/presenters/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ pub use lab::{
pub use pack::present_pack_report;
pub use project::present_project_list;
pub use provider::{present_provider_detected, present_provider_list, present_provider_set};
pub use session::{present_session_analysis, present_session_list, present_session_state};
pub use session::{present_session_detail, present_session_list, present_session_state};
pub use watch::{
present_watch_attached, present_watch_error, present_watch_rotated,
present_watch_start_provider, present_watch_start_session, present_watch_stream_update,
Expand Down
124 changes: 69 additions & 55 deletions crates/agtrace-cli/src/presentation/presenters/session.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
use crate::args::hints::{cmd, fmt};
use crate::presentation::view_models::{
AgentStepViewModel, CommandResultViewModel, ContextUsage, ContextWindowSummary,
ContextWindowUsageViewModel, FilterSummary, Guidance, SessionAnalysisViewModel, SessionHeader,
SessionListEntry, SessionListViewModel, SpawnedChildViewModel, StatusBadge,
StreamStateViewModel, TurnAnalysisViewModel, TurnMetrics as ViewTurnMetrics,
ContextWindowUsageViewModel, FilterSummary, Guidance, SessionDetailViewModel,
SessionInfoViewModel, SessionListEntry, SessionListViewModel, SpawnContextViewModel,
SpawnedChildViewModel, StatusBadge, StreamAnalysisViewModel, StreamStateViewModel,
TurnAnalysisViewModel, TurnMetrics as ViewTurnMetrics,
};
use agtrace_sdk::ChildSessionInfo;
use agtrace_sdk::types::{AgentSession, SessionAnalysisExt, SessionSummary};
use agtrace_sdk::types::{AgentSession, SessionAnalysisExt, SessionSummary, StreamId};

pub fn present_session_list(
sessions: Vec<SessionSummary>,
Expand Down Expand Up @@ -92,46 +93,75 @@ fn add_session_list_guidance(
result
}

/// Present session analysis with context-aware metrics
/// Present a full session (all streams) as a single view model.
///
/// Streams are ordered Main-first, then by stream_id. Producing one view model
/// for the whole session guarantees `--format json` emits exactly one document.
#[allow(clippy::too_many_arguments)]
pub fn present_session_analysis(
session: &AgentSession,
pub fn present_session_detail(
streams: &[AgentSession],
session_id: &str,
provider: &str,
project_hash: &str,
project_root: Option<&str>,
session_spawned_by: Option<&agtrace_sdk::types::SpawnContext>,
model: &str,
max_context: Option<u32>,
log_files: Vec<String>,
children: &[ChildSessionInfo],
) -> CommandResultViewModel<SessionAnalysisViewModel> {
let view = build_session_analysis_view(
session,
session_id,
provider,
project_hash,
project_root,
model,
max_context,
log_files,
children,
);
) -> CommandResultViewModel<SessionDetailViewModel> {
// Order: Main first, then others by stream_id string
let mut ordered: Vec<&AgentSession> = streams.iter().collect();
ordered.sort_by(|a, b| match (&a.stream_id, &b.stream_id) {
(StreamId::Main, StreamId::Main) => std::cmp::Ordering::Equal,
(StreamId::Main, _) => std::cmp::Ordering::Less,
(_, StreamId::Main) => std::cmp::Ordering::Greater,
(a_id, b_id) => a_id.as_str().cmp(&b_id.as_str()),
});

let stream_views = ordered
.iter()
.map(|session| {
// Children (subagent sessions in separate files) attach to the main stream only
let stream_children: &[ChildSessionInfo] =
if matches!(session.stream_id, StreamId::Main) {
children
} else {
&[]
};
build_stream_analysis(session, max_context, stream_children)
})
.collect();

let view = SessionDetailViewModel {
session: SessionInfoViewModel {
session_id: session_id.to_string(),
provider: provider.to_string(),
project_hash: project_hash.to_string(),
project_root: project_root.map(|s| s.to_string()),
model: Some(model.to_string()),
log_files,
spawned_by: session_spawned_by.map(present_spawn_context),
},
streams: stream_views,
};

let result = CommandResultViewModel::new(view);
add_session_analysis_guidance(result)
result.with_badge(StatusBadge::success("Session Analysis"))
}

#[allow(clippy::too_many_arguments)]
fn build_session_analysis_view(
fn present_spawn_context(ctx: &agtrace_sdk::types::SpawnContext) -> SpawnContextViewModel {
SpawnContextViewModel {
turn_index: ctx.turn_index,
step_index: ctx.step_index,
}
}

fn build_stream_analysis(
session: &AgentSession,
session_id: &str,
provider: &str,
project_hash: &str,
project_root: Option<&str>,
model: &str,
max_context: Option<u32>,
log_files: Vec<String>,
children: &[ChildSessionInfo],
) -> SessionAnalysisViewModel {
) -> StreamAnalysisViewModel {
use crate::presentation::formatters::time;
use std::collections::HashMap;

Expand Down Expand Up @@ -173,24 +203,6 @@ fn build_session_analysis_view(
.first()
.and_then(|t| t.steps.first().map(|s| time::format_time(s.timestamp)));

// Build header
let header = SessionHeader {
session_id: session_id.to_string(),
stream_id: session.stream_id.as_str(),
provider: provider.to_string(),
project_hash: project_hash.to_string(),
project_root: project_root.map(|s| s.to_string()),
model: Some(model.to_string()),
status: if session.turns.is_empty() {
"Empty".to_string()
} else {
"Complete".to_string()
},
duration,
start_time,
log_files,
};

// Build context summary (raw data only)
let total_tokens = metrics.last().map(|m| m.prev_total + m.delta).unwrap_or(0);
let context_summary = ContextWindowSummary {
Expand All @@ -212,19 +224,21 @@ fn build_session_analysis_view(
})
.collect();

SessionAnalysisViewModel {
header,
StreamAnalysisViewModel {
stream_id: session.stream_id.as_str(),
spawned_by: session.spawned_by.as_ref().map(present_spawn_context),
status: if session.turns.is_empty() {
"Empty".to_string()
} else {
"Complete".to_string()
},
duration,
start_time,
context_summary,
turns,
}
}

fn add_session_analysis_guidance(
result: CommandResultViewModel<SessionAnalysisViewModel>,
) -> CommandResultViewModel<SessionAnalysisViewModel> {
result.with_badge(StatusBadge::success("Session Analysis"))
}

fn build_turn_analysis(
turn: &agtrace_sdk::types::AgentTurn,
metric: &agtrace_sdk::types::TurnMetrics,
Expand Down
7 changes: 4 additions & 3 deletions crates/agtrace-cli/src/presentation/view_models/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,10 @@ pub use provider::{
pub use result::CommandResultViewModel;
pub use session::{
AgentStepViewModel, ContextUsage, ContextWindowSummary, ContextWindowUsageViewModel,
FilterSummary, SessionAnalysisViewModel, SessionHeader, SessionListEntry, SessionListViewModel,
SpawnedChildViewModel, StepItemViewModel, StreamStateViewModel, TurnAnalysisViewModel,
TurnMetrics, TurnUsageViewModel,
FilterSummary, SessionDetailViewModel, SessionInfoViewModel, SessionListEntry,
SessionListViewModel, SpawnContextViewModel, SpawnedChildViewModel, StepItemViewModel,
StreamAnalysisViewModel, StreamStateViewModel, TurnAnalysisViewModel, TurnMetrics,
TurnUsageViewModel,
};
pub use watch::{WatchEventViewModel, WatchStreamStateViewModel, WatchTargetViewModel};
pub use watch_tui::{
Expand Down
51 changes: 39 additions & 12 deletions crates/agtrace-cli/src/presentation/view_models/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,26 +33,53 @@ pub struct FilterSummary {
pub limit: usize,
}

/// Session analysis view - TUI-centric performance report
/// Session detail view - a single document covering the whole session.
///
/// A session may contain multiple event streams (the main conversation plus
/// sidechains/subagents). They are all embedded in `streams` so that one
/// `session show` invocation always produces exactly one document, regardless
/// of output format.
#[derive(Debug, Serialize)]
pub struct SessionAnalysisViewModel {
pub header: SessionHeader,
pub context_summary: ContextWindowSummary,
pub turns: Vec<TurnAnalysisViewModel>,
pub struct SessionDetailViewModel {
pub session: SessionInfoViewModel,
/// All streams in this session. The main stream comes first.
pub streams: Vec<StreamAnalysisViewModel>,
}

/// Session-scoped metadata (shared by all streams).
#[derive(Debug, Serialize)]
pub struct SessionHeader {
pub struct SessionInfoViewModel {
pub session_id: String,
pub stream_id: String,
pub provider: String,
pub project_hash: String,
pub project_root: Option<String>,
pub model: Option<String>,
pub log_files: Vec<String>,
/// Spawn context when this entire session is a subagent session
/// (e.g. Codex subagents stored in separate files).
#[serde(skip_serializing_if = "Option::is_none")]
pub spawned_by: Option<SpawnContextViewModel>,
}

/// Analysis of a single event stream (main conversation or a sidechain).
#[derive(Debug, Serialize)]
pub struct StreamAnalysisViewModel {
pub stream_id: String,
/// Where this stream was spawned from in the parent stream (sidechains only).
#[serde(skip_serializing_if = "Option::is_none")]
pub spawned_by: Option<SpawnContextViewModel>,
pub status: String,
pub duration: Option<String>,
pub start_time: Option<String>,
pub log_files: Vec<String>,
pub context_summary: ContextWindowSummary,
pub turns: Vec<TurnAnalysisViewModel>,
}

/// Spawn location (0-based indices) within the parent stream.
#[derive(Debug, Serialize)]
pub struct SpawnContextViewModel {
pub turn_index: usize,
pub step_index: usize,
}

#[derive(Debug, Serialize)]
Expand Down Expand Up @@ -194,10 +221,10 @@ impl CreateView for SessionListViewModel {
}
}

impl CreateView for SessionAnalysisViewModel {
impl CreateView for SessionDetailViewModel {
fn create_view<'a>(&'a self, mode: ViewMode) -> Box<dyn fmt::Display + 'a> {
use crate::presentation::views::session::SessionAnalysisView;
Box::new(SessionAnalysisView::new(self, mode))
use crate::presentation::views::session::SessionDetailView;
Box::new(SessionDetailView::new(self, mode))
}
}

Expand All @@ -211,7 +238,7 @@ impl fmt::Display for SessionListViewModel {
}
}

impl fmt::Display for SessionAnalysisViewModel {
impl fmt::Display for SessionDetailViewModel {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", self.create_view(ViewMode::default()))
}
Expand Down
Loading
Loading