use anyhow::{Context, Result, bail};
use std::collections::HashMap;
use std::time::{Duration, Instant};
use claude_wrapper::history::{HistoryEntry, HistoryRoot, SessionLog};
use claude_wrapper::types::QueryResult;
use crate::SuccessEnvelope;
use crate::cost::{Usage, cost_breakdown, usage_by_model};
use crate::history::extract_message_text;
use crate::output::{format_count, looks_like_refusal, truncate_arg};
use crate::rates::Rates;
use crate::render;
const DEFAULT_WAIT_TIMEOUT_SECS: u64 = 600;
const POLL_INTERVAL: Duration = Duration::from_secs(1);
pub fn run(args: &crate::cli::ShowArgs) -> Result<()> {
let root = HistoryRoot::home().context("locating ~/.claude/projects")?;
let log = if args.wait {
wait_for_complete(&root, args)?
} else {
load_session(&root, &args.session_id)?
};
render_session(&root, &log, args)
}
fn load_session(root: &HistoryRoot, session_id: &str) -> Result<SessionLog> {
match root.find_session(session_id) {
Ok(Some(_)) => root
.read_session(session_id)
.with_context(|| format!("reading session `{session_id}`")),
Ok(None) => bail!("session `{session_id}` not found"),
Err(e) => Err(e).with_context(|| format!("looking up session `{session_id}`")),
}
}
fn wait_for_complete(root: &HistoryRoot, args: &crate::cli::ShowArgs) -> Result<SessionLog> {
let timeout_secs = args.timeout.unwrap_or(DEFAULT_WAIT_TIMEOUT_SECS);
let deadline = (timeout_secs != 0).then(|| Instant::now() + Duration::from_secs(timeout_secs));
loop {
match root.find_session(&args.session_id) {
Ok(Some(_)) => {
let log = root
.read_session(&args.session_id)
.with_context(|| format!("reading session `{}`", args.session_id))?;
if is_complete(&log) {
return Ok(log);
}
}
Ok(None) => {} Err(e) => {
return Err(e).with_context(|| format!("looking up session `{}`", args.session_id));
}
}
if let Some(deadline) = deadline
&& Instant::now() >= deadline
{
return Err(anyhow::Error::new(claude_wrapper::Error::Timeout {
timeout_seconds: timeout_secs,
}))
.with_context(|| {
format!(
"waited {timeout_secs}s for session `{}` to complete",
args.session_id
)
});
}
std::thread::sleep(POLL_INTERVAL);
}
}
fn is_complete(log: &SessionLog) -> bool {
let last_assistant = log.entries.iter().rev().find_map(|e| match e {
HistoryEntry::Assistant { message, .. } => Some(message),
_ => None,
});
match last_assistant
.and_then(|m| m.get("stop_reason"))
.and_then(|v| v.as_str())
{
Some("tool_use") => false,
Some(_) => true,
None => false,
}
}
fn render_session(root: &HistoryRoot, log: &SessionLog, args: &crate::cli::ShowArgs) -> Result<()> {
let (result_text, num_turns) = reconstruct_answer(log);
let jsonl_path = root
.path()
.join(&log.project_slug)
.join(format!("{}.jsonl", log.session_id));
let by_model = std::fs::read_to_string(&jsonl_path)
.map(|text| usage_by_model(&text))
.unwrap_or_default();
let rates = Rates::resolve(None).ok();
let cost_usd = rates.as_ref().and_then(|r| cost_breakdown(&by_model, r).1);
let qr = QueryResult {
result: result_text,
session_id: log.session_id.clone(),
cost_usd,
duration_ms: None,
num_turns: Some(num_turns),
is_error: false,
extra: HashMap::new(),
};
let refusal = looks_like_refusal(&qr.result);
if args.json {
let envelope = SuccessEnvelope {
version: 1,
result: &qr,
refusal,
};
println!("{}", serde_json::to_string_pretty(&envelope)?);
if args.metrics {
print_metrics(&by_model, rates.as_ref());
}
return Ok(());
}
let style = render::Style::detect_for_subcommand();
render::print_body(&qr.result, &style);
print_footer(&qr, refusal, &style);
if args.metrics {
print_metrics(&by_model, rates.as_ref());
}
Ok(())
}
fn reconstruct_answer(log: &SessionLog) -> (String, u32) {
let mut result_text = String::new();
let mut num_turns: u32 = 0;
for entry in &log.entries {
if let HistoryEntry::Assistant { message, .. } = entry {
num_turns += 1;
if let Some(text) = extract_message_text(message) {
result_text = text;
}
}
}
(result_text, num_turns)
}
fn print_footer(qr: &QueryResult, refusal: bool, style: &render::Style) {
render::print_meta_blank();
if refusal {
render::print_warning("response looks like a refusal", style);
}
let id = qr.session_id.get(..8).unwrap_or(&qr.session_id);
let turns = qr.num_turns.unwrap_or(0);
let cost = match qr.cost_usd {
Some(c) => format!("${c:.4}"),
None => "cost unavailable".to_string(),
};
render::print_meta(
&format!(
"session {id} . turns {turns} . {cost} . reconstructed envelope (duration unavailable)"
),
style,
);
}
fn print_metrics(by_model: &HashMap<String, Usage>, rates: Option<&Rates>) {
if by_model.is_empty() {
eprintln!("no per-model usage recorded for this session");
return;
}
let mut models: Vec<(&String, &Usage)> = by_model.iter().collect();
models.sort_by(|a, b| b.1.total().cmp(&a.1.total()).then(a.0.cmp(b.0)));
eprintln!();
eprintln!(
"{:<28} {:>9} {:>9} {:>9} {:>9} {:>10}",
"MODEL", "IN", "OUT", "CACHE_R", "CACHE_W", "COST"
);
for (model, u) in models {
let cost = rates
.and_then(|r| r.cost_usd(model, u.input, u.output, u.cache_read, u.cache_write))
.map(|c| format!("${c:.4}"))
.unwrap_or_else(|| "-".to_string());
eprintln!(
"{:<28} {:>9} {:>9} {:>9} {:>9} {:>10}",
truncate_arg(model, 28),
format_count(u.input),
format_count(u.output),
format_count(u.cache_read),
format_count(u.cache_write),
cost,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::{Map, json};
fn assistant(text: Option<&str>) -> HistoryEntry {
let message = match text {
Some(t) => json!({"content": [{"type": "text", "text": t}]}),
None => json!({"content": [{"type": "tool_use", "name": "Read", "input": {}}]}),
};
HistoryEntry::Assistant {
uuid: None,
timestamp: None,
message,
rest: Map::new(),
}
}
fn assistant_stop(reason: Option<&str>) -> HistoryEntry {
let message = match reason {
Some(r) => json!({"content": [{"type": "text", "text": "hi"}], "stop_reason": r}),
None => json!({"content": [{"type": "text", "text": "hi"}]}),
};
HistoryEntry::Assistant {
uuid: None,
timestamp: None,
message,
rest: Map::new(),
}
}
fn other(tag: &str) -> HistoryEntry {
HistoryEntry::Other {
type_tag: tag.to_string(),
raw: json!({"type": tag}),
}
}
fn user() -> HistoryEntry {
HistoryEntry::User {
uuid: None,
timestamp: None,
cwd: None,
git_branch: None,
message: json!({"content": "hi"}),
rest: Map::new(),
}
}
fn log(entries: Vec<HistoryEntry>) -> SessionLog {
SessionLog {
session_id: "sess-1".to_string(),
project_slug: "-tmp-proj".to_string(),
entries,
}
}
#[test]
fn reconstruct_takes_last_assistant_text_and_counts_turns() {
let l = log(vec![
user(),
assistant(Some("first answer")),
user(),
assistant(Some("final answer")),
]);
let (text, turns) = reconstruct_answer(&l);
assert_eq!(text, "final answer");
assert_eq!(turns, 2);
}
#[test]
fn reconstruct_skips_trailing_tool_only_turn_for_text() {
let l = log(vec![user(), assistant(Some("the answer")), assistant(None)]);
let (text, turns) = reconstruct_answer(&l);
assert_eq!(text, "the answer");
assert_eq!(turns, 2);
}
#[test]
fn reconstruct_empty_log_is_empty_zero() {
let (text, turns) = reconstruct_answer(&log(vec![]));
assert_eq!(text, "");
assert_eq!(turns, 0);
}
#[test]
fn load_session_missing_is_clean_not_found() {
let dir = tempfile::tempdir().unwrap();
let root = HistoryRoot::at(dir.path());
let err = load_session(&root, "missing-id").unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("not found"), "got: {msg}");
}
#[test]
fn load_session_real_error_propagates_not_as_not_found() {
let file = tempfile::NamedTempFile::new().unwrap();
let root = HistoryRoot::at(file.path());
let err = load_session(&root, "any-id").unwrap_err();
let msg = format!("{err:#}");
assert!(
!msg.contains("not found"),
"a real I/O error must not masquerade as not-found: {msg}"
);
assert!(msg.contains("looking up session"), "got: {msg}");
}
#[test]
fn is_complete_terminal_stop_reason_is_true() {
let l = log(vec![user(), assistant_stop(Some("end_turn"))]);
assert!(is_complete(&l));
}
#[test]
fn is_complete_tool_use_is_false() {
let l = log(vec![user(), assistant_stop(Some("tool_use"))]);
assert!(!is_complete(&l));
}
#[test]
fn is_complete_trailing_user_entry_is_false() {
let l = log(vec![assistant_stop(Some("tool_use")), user()]);
assert!(!is_complete(&l));
}
#[test]
fn is_complete_empty_log_is_false() {
assert!(!is_complete(&log(vec![])));
}
#[test]
fn is_complete_missing_stop_reason_is_false() {
let l = log(vec![user(), assistant_stop(None)]);
assert!(!is_complete(&l));
}
#[test]
fn is_complete_looks_past_trailing_other_metadata() {
let l = log(vec![
user(),
assistant_stop(Some("end_turn")),
other("agent-name"),
]);
assert!(is_complete(&l));
}
#[test]
fn is_complete_uses_last_assistant_turn() {
let l = log(vec![
user(),
assistant_stop(Some("end_turn")),
user(),
assistant_stop(Some("tool_use")),
]);
assert!(!is_complete(&l));
}
}