use std::{
collections::HashSet,
fs,
path::{Path, PathBuf},
};
use anyhow::Context;
use rho_providers::model::{ContentBlock, Message, ToolCall};
use rho_sdk::ApprovalRequest;
use serde::Deserialize;
use super::cases::{
replay_request, spread_evenly, CaseSource, Decision, FixtureEntry, FixturePending,
FIXTURE_WORKSPACE,
};
use crate::{
history_message::HistoryMessage,
session::replay_points::{self, HistorySegment},
};
#[derive(Debug)]
pub(super) struct EvalBatch {
pub id: String,
pub source: CaseSource,
pub category: Option<String>,
pub workspace: PathBuf,
pub history: Vec<Message>,
pub members: Vec<EvalMember>,
}
#[derive(Debug)]
pub(super) struct EvalMember {
pub id: String,
pub label: Option<Decision>,
pub pending: ApprovalRequest,
pub call_id: String,
pub summary: String,
}
pub(super) fn load_batch_file(path: &Path) -> anyhow::Result<Vec<EvalBatch>> {
let text =
fs::read_to_string(path).with_context(|| format!("could not read {}", path.display()))?;
parse_batches(&text).with_context(|| format!("invalid batch file {}", path.display()))
}
pub(super) fn parse_batches(text: &str) -> anyhow::Result<Vec<EvalBatch>> {
let mut ids = HashSet::new();
let mut batches = Vec::new();
for (index, line) in text.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
let line_number = index + 1;
let batch: FixtureBatch =
serde_json::from_str(line).with_context(|| format!("line {line_number}"))?;
anyhow::ensure!(
ids.insert(batch.id.clone()),
"line {line_number}: duplicate batch id {}",
batch.id
);
anyhow::ensure!(
batch.pending.len() >= 2,
"line {line_number}: batch {} needs at least 2 pending requests",
batch.id
);
let mut member_ids = HashSet::new();
for member in &batch.pending {
anyhow::ensure!(
member_ids.insert(member.id.as_str()),
"line {line_number}: duplicate member id {} in batch {}",
member.id,
batch.id
);
}
batches.push(batch.into_eval_batch());
}
Ok(batches)
}
pub(super) fn replay_session(path: &Path, per_session: usize) -> anyhow::Result<Vec<EvalBatch>> {
let (session_id, segments) = replay_points::segments(path)?;
let cwd = replay_points::session_cwd(path)?;
Ok(replay_batches(&session_id, &cwd, &segments, per_session))
}
pub(super) fn replay_batches(
session_id: &str,
cwd: &Path,
segments: &[HistorySegment],
per_session: usize,
) -> Vec<EvalBatch> {
let mut groups = Vec::new();
for (segment_index, segment) in segments.iter().enumerate() {
let history = &segment.messages;
for (index, message) in history.iter().enumerate().skip(segment.new_from) {
let blocks = match HistoryMessage::of(message) {
HistoryMessage::Assistant(blocks) => blocks,
HistoryMessage::EnrichedAssistant(assistant) => assistant.content.as_slice(),
HistoryMessage::AbortedAssistant(_)
| HistoryMessage::System(_)
| HistoryMessage::User(_)
| HistoryMessage::CompactionSummary(_)
| HistoryMessage::ToolResult(_)
| HistoryMessage::ToolImageSupplement(_) => continue,
};
let members: Vec<EvalMember> = blocks
.iter()
.filter_map(|block| match block {
ContentBlock::ToolCall(call) => Some(call),
_ => None,
})
.filter_map(|call| {
let (capability, summary) = replay_request(call, cwd)?;
Some(EvalMember {
id: call.id.clone(),
label: None,
pending: ApprovalRequest::new(capability, ""),
call_id: call.id.clone(),
summary,
})
})
.collect();
if members.len() >= 2 {
groups.push((segment_index, history, index, members));
}
}
}
let chosen: HashSet<usize> = spread_evenly(groups.len(), per_session)
.into_iter()
.collect();
groups
.into_iter()
.enumerate()
.filter(|(choice, _)| chosen.contains(choice))
.map(|(_, (segment_index, history, index, members))| EvalBatch {
id: format!("{session_id}:{segment_index}.{index}"),
source: CaseSource::Replay,
category: None,
workspace: cwd.to_path_buf(),
history: history[..=index].to_vec(),
members,
})
.collect()
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct FixtureBatch {
id: String,
category: String,
#[serde(default, rename = "note")]
_note: String,
#[serde(default)]
history: Vec<FixtureEntry>,
pending: Vec<FixtureMember>,
}
#[derive(Deserialize)]
struct FixtureMember {
id: String,
label: Decision,
#[serde(flatten)]
pending: FixturePending,
}
impl FixtureBatch {
fn into_eval_batch(self) -> EvalBatch {
let workspace = PathBuf::from(FIXTURE_WORKSPACE);
let mut history: Vec<Message> = self
.history
.into_iter()
.map(FixtureEntry::into_message)
.collect();
let mut calls = Vec::new();
let members = self
.pending
.into_iter()
.map(|member| {
let (name, arguments, capability, summary) =
member.pending.into_request(&workspace);
let call_id = format!("pending-{}", member.id);
calls.push(ContentBlock::ToolCall(ToolCall {
id: call_id.clone(),
name: name.into(),
arguments,
}));
EvalMember {
id: member.id,
label: Some(member.label),
pending: ApprovalRequest::new(capability, ""),
call_id,
summary,
}
})
.collect();
history.push(Message::Assistant(calls));
EvalBatch {
id: self.id,
source: CaseSource::Fixture,
category: Some(self.category),
workspace,
history,
members,
}
}
}