use crate::{setup, GlobalOpts};
use anyhow::{bail, Context, Result};
use mecha_core::agent::{Agent, RunContext};
use mecha_core::config::PermissionMode;
use mecha_core::replay::{extract, Divergence};
use mecha_core::replay_run::{drive, replay_registry, OnDivergence};
use mecha_core::session::Session;
use mecha_core::tool::ModeApprover;
use std::path::PathBuf;
use std::sync::Arc;
use tokio_util::sync::CancellationToken;
#[derive(clap::Args, Debug)]
pub struct Args {
pub session: String,
#[arg(long, value_name = "stop|error|live", default_value = "stop")]
pub on_divergence: String,
#[arg(long)]
pub json: bool,
}
pub async fn execute(global: &GlobalOpts, args: Args) -> Result<()> {
let mode = match args.on_divergence.as_str() {
"stop" => OnDivergence::Stop,
"error" => OnDivergence::Error,
"live" => OnDivergence::Live,
other => bail!("--on-divergence must be stop, error, or live, not `{other}`"),
};
let path = resolve_session(&args.session)?;
let (meta, convo) = Session::load(&path)?;
let configs = Session::run_configs(&path)?;
let Some(recorded) = configs.first().cloned() else {
bail!(
"{} has no RunConfig record, so the replay cannot rebuild the run \
(system prompt, tool list, budgets). Sessions recorded before that \
record existed cannot be replayed",
path.display()
);
};
if configs.len() > 1 {
eprintln!(
"note: this session was attached {} times; replaying under its first config",
configs.len()
);
}
let trajectory = extract(&convo.messages);
if trajectory.turns.is_empty() {
bail!("the transcript contains no user turns; nothing to replay");
}
if trajectory.steered {
eprintln!(
"note: the recording was steered mid-run; steering cannot be re-injected, \
so the comparison is approximate"
);
}
let mut opts = global.clone();
if opts.provider.is_none() {
opts.provider = Some(recorded.provider.clone());
}
let prepared = setup::prepare(&opts, false).await?;
let provider_name = opts.provider.clone().unwrap();
let provider_cfg = prepared
.config
.providers
.get(&provider_name)
.with_context(|| {
format!("provider `{provider_name}` is not in the config; add it or pass -p")
})?;
let provider = mecha_core::provider::build(provider_cfg)?;
let model = opts.model.clone().unwrap_or_else(|| {
if global.provider.is_some() {
provider_cfg
.model
.clone()
.unwrap_or_else(|| provider.default_model().to_string())
} else {
recorded.model.clone()
}
});
let mut agent_cfg = prepared.config.agent.clone();
agent_cfg.system_prompt = recorded.system_prompt.clone();
agent_cfg.system_prompt_file = None;
agent_cfg.effort = recorded.effort;
agent_cfg.thinking = recorded.thinking;
agent_cfg.cache_prompt = recorded.cache_prompt;
agent_cfg.max_tokens = recorded.max_tokens;
agent_cfg.max_turns = recorded.max_turns;
agent_cfg.max_output_tokens = recorded.max_output_tokens;
agent_cfg.max_cost_usd = recorded.max_cost_usd;
agent_cfg.compact_at_tokens = recorded.compact_at_tokens;
agent_cfg.compact_keep_recent = recorded.compact_keep_recent;
let cancel = CancellationToken::new();
let registry = replay_registry(
&recorded.tools,
prepared.agent.registry(),
trajectory.calls.clone(),
mode,
cancel.clone(),
)?;
let approver: Arc<dyn mecha_core::tool::Approver> = match mode {
OnDivergence::Live => Arc::new(ModeApprover {
mode: prepared.config.tools.permission_mode,
}),
_ => Arc::new(ModeApprover {
mode: PermissionMode::Allow,
}),
};
let mut tool_ctx = mecha_core::tool::ToolCtx {
workspace: recorded.workspace.clone(),
..Default::default()
};
if !recorded.workspace.exists() {
eprintln!(
"note: the recorded workspace {} no longer exists; fine for a pure \
replay, fatal for --on-divergence=live",
recorded.workspace.display()
);
tool_ctx.workspace = std::env::temp_dir();
}
let agent = Agent::new(
provider,
registry,
Arc::clone(&approver),
tool_ctx.clone(),
agent_cfg,
Some(model.clone()),
)?
.with_pricing(provider_cfg.pricing());
let cx = RunContext::new(tool_ctx, approver)
.with_cancel(cancel)
.with_compact_at(recorded.compact_at_tokens);
eprintln!(
"replaying {} · {} turns, {} recorded calls · {} ({})",
meta.id,
trajectory.turns.len(),
trajectory.calls.len(),
model,
provider_name
);
let report = drive(&agent, &cx, &trajectory).await?;
if args.json {
println!(
"{}",
serde_json::json!({
"session": meta.id,
"provider": provider_name,
"model": model,
"recorded_calls": report.recorded_calls,
"replayed_calls": report.replayed_calls.len(),
"turns": report.turns,
"stopped_early": report.stopped_early,
"divergences": report.divergences,
"final_text": report.final_text,
})
);
} else {
render_report(&report, &trajectory.final_text);
}
if mode == OnDivergence::Error && !report.divergences.is_empty() {
bail!(
"replay diverged: {} difference(s)",
report.divergences.len()
);
}
Ok(())
}
fn render_report(report: &mecha_core::replay_run::ReplayReport, recorded_final: &str) {
if report.divergences.is_empty() {
println!(
"replay matched: {} calls over {} turn(s), no divergence",
report.recorded_calls, report.turns
);
} else {
println!(
"replayed {} of {} recorded calls over {} turn(s){}",
report.replayed_calls.len(),
report.recorded_calls,
report.turns,
if report.stopped_early {
", stopped at divergence"
} else {
""
}
);
for d in &report.divergences {
match d {
Divergence::Tool {
index,
expected,
actual,
} => {
println!(" call #{index}: recorded `{expected}`, replayed `{actual}`")
}
Divergence::Arguments {
index,
tool,
expected,
actual,
} => {
println!(" call #{index} ({tool}): same tool, different arguments");
println!(" recorded: {expected}");
println!(" replayed: {actual}");
}
Divergence::Extra { index, actual } => {
println!(" call #{index}: replay kept going with `{actual}` after the recording ended")
}
Divergence::Missing { index, expected } => {
println!(" call #{index}: recording continued with `{expected}`; the replay stopped")
}
}
}
}
let structural = report.structural().count();
let cosmetic = report.divergences.len() - structural;
if !report.divergences.is_empty() {
println!(" {structural} structural, {cosmetic} argument-only");
}
if !report.final_text.is_empty() && report.final_text != recorded_final {
println!("final answer differs:");
println!(" recorded: {}", first_line(recorded_final));
println!(" replayed: {}", first_line(&report.final_text));
}
}
fn first_line(s: &str) -> String {
let line = s.lines().next().unwrap_or("").trim();
if line.len() > 120 {
format!("{}…", &line[..120])
} else {
line.to_string()
}
}
fn resolve_session(arg: &str) -> Result<PathBuf> {
let as_path = PathBuf::from(arg);
if as_path.is_file() {
return Ok(as_path);
}
Session::find(&Session::default_dir()?, arg)
}