use std::{
collections::HashSet,
fs,
path::{Component, Path, PathBuf},
};
use anyhow::Context;
use rho_providers::model::{ContentBlock, Message, ToolCall, ToolResult};
use rho_sdk::{
ApprovalRequest, CapabilityRequest, CapabilitySource, PathScope, ProcessEnvironment,
ProcessExecution, ProcessInvocation, ProcessOutputLimits,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use crate::{
history_message::HistoryMessage,
session::replay_points::{self, HistorySegment},
};
pub(super) const FIXTURE_WORKSPACE: &str = "/workspace";
const FIXTURE_PENDING_CALL_ID: &str = "pending";
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub(super) enum Decision {
Allow,
Deny,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub(super) enum CaseSource {
Fixture,
Replay,
}
#[derive(Clone, Debug)]
pub(super) struct EvalCase {
pub id: String,
pub source: CaseSource,
pub category: Option<String>,
pub label: Option<Decision>,
pub workspace: PathBuf,
pub history: Vec<Message>,
pub pending: ApprovalRequest,
pub pending_call_id: String,
pub summary: String,
}
pub(super) fn load_fixture_file(path: &Path) -> anyhow::Result<Vec<EvalCase>> {
let text =
fs::read_to_string(path).with_context(|| format!("could not read {}", path.display()))?;
parse_fixture_cases(&text).with_context(|| format!("invalid case file {}", path.display()))
}
pub(super) fn parse_fixture_cases(text: &str) -> anyhow::Result<Vec<EvalCase>> {
let mut ids = HashSet::new();
let mut cases = Vec::new();
for (index, line) in text.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
let line_number = index + 1;
let case: FixtureCase =
serde_json::from_str(line).with_context(|| format!("line {line_number}"))?;
anyhow::ensure!(
ids.insert(case.id.clone()),
"line {line_number}: duplicate case id {}",
case.id
);
cases.push(case.into_eval_case());
}
Ok(cases)
}
pub(super) fn replay_session(path: &Path, per_session: usize) -> anyhow::Result<Vec<EvalCase>> {
let (session_id, segments) = replay_points::segments(path)?;
let cwd = replay_points::session_cwd(path)?;
Ok(replay_cases(&session_id, &cwd, &segments, per_session))
}
pub(super) fn replay_cases(
session_id: &str,
cwd: &Path,
segments: &[HistorySegment],
per_session: usize,
) -> Vec<EvalCase> {
let mut eligible = 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,
};
for block in blocks {
let ContentBlock::ToolCall(call) = block else {
continue;
};
if let Some((capability, summary)) = replay_request(call, cwd) {
eligible.push((segment_index, history, index, &call.id, capability, summary));
}
}
}
}
spread_evenly(eligible.len(), per_session)
.into_iter()
.map(|choice| {
let (segment_index, history, index, call_id, capability, summary) = &eligible[choice];
EvalCase {
id: format!("{session_id}:{segment_index}.{index}:{call_id}"),
source: CaseSource::Replay,
category: None,
label: None,
workspace: cwd.to_path_buf(),
history: history[..=*index].to_vec(),
pending: ApprovalRequest::new(capability.clone(), ""),
pending_call_id: (*call_id).clone(),
summary: summary.clone(),
}
})
.collect()
}
pub(super) fn replay_request(call: &ToolCall, cwd: &Path) -> Option<(CapabilityRequest, String)> {
let arguments = call.arguments.clone();
match call.name.as_str() {
"bash" => {
let args: ReplayedBash = serde_json::from_value(arguments).ok()?;
(args.timeout_seconds != Some(0))
.then(|| (process_request(cwd, &args.command), args.command))
}
"write" => {
let args: ReplayedWrite = serde_json::from_value(arguments).ok()?;
Some(path_request(PathAccess::Write, cwd, &args.path))
}
_ => None,
}
}
#[derive(Deserialize)]
struct ReplayedBash {
command: String,
timeout_seconds: Option<u64>,
}
#[derive(Deserialize)]
struct ReplayedWrite {
path: String,
#[serde(rename = "content")]
_content: String,
}
pub(super) fn spread_evenly(len: usize, count: usize) -> Vec<usize> {
if len <= count {
return (0..len).collect();
}
let last = len - 1;
let mut chosen = (0..count)
.map(|step| match count {
1 => last,
_ => step * last / (count - 1),
})
.collect::<Vec<_>>();
chosen.dedup();
chosen
}
fn process_request(cwd: &Path, command: &str) -> CapabilityRequest {
let execution = ProcessExecution::new(
cwd,
ProcessInvocation::shell_from_path("bash", vec!["-lc".into()], command),
ProcessEnvironment::InheritAll,
ProcessOutputLimits::new( 0, None),
);
CapabilityRequest::process(execution, CapabilitySource::built_in_tool("bash"))
}
#[derive(Clone, Copy)]
enum PathAccess {
Read,
Write,
}
fn path_request(access: PathAccess, workspace: &Path, raw: &str) -> (CapabilityRequest, String) {
let path = normalize_lexically(&workspace.join(raw));
let scope = if path.starts_with(workspace) {
PathScope::PrimaryWorkspace
} else {
PathScope::UnrestrictedFilesystem
};
let summary = path.to_string_lossy().into_owned();
let request = match access {
PathAccess::Read => {
CapabilityRequest::read_path(path, scope, CapabilitySource::built_in_tool("read_file"))
}
PathAccess::Write => {
CapabilityRequest::write_path(path, scope, CapabilitySource::built_in_tool("write"))
}
};
(request, summary)
}
fn normalize_lexically(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::ParentDir => {
normalized.pop();
}
Component::CurDir => {}
Component::Prefix(_) | Component::RootDir | Component::Normal(_) => {
normalized.push(component)
}
}
}
normalized
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct FixtureCase {
id: String,
label: Decision,
category: String,
#[serde(default, rename = "note")]
_note: String,
#[serde(default)]
history: Vec<FixtureEntry>,
pending: FixturePending,
}
impl FixtureCase {
fn into_eval_case(self) -> EvalCase {
let workspace = PathBuf::from(FIXTURE_WORKSPACE);
let mut history: Vec<Message> = self
.history
.into_iter()
.map(FixtureEntry::into_message)
.collect();
let (name, arguments, capability, summary) = self.pending.into_request(&workspace);
history.push(Message::Assistant(vec![ContentBlock::ToolCall(ToolCall {
id: FIXTURE_PENDING_CALL_ID.into(),
name: name.into(),
arguments,
})]));
EvalCase {
id: self.id,
source: CaseSource::Fixture,
category: Some(self.category),
label: Some(self.label),
workspace,
history,
pending: ApprovalRequest::new(capability, ""),
pending_call_id: FIXTURE_PENDING_CALL_ID.into(),
summary,
}
}
}
#[derive(Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub(super) enum FixtureEntry {
User(String),
Call {
id: String,
name: String,
arguments: Value,
},
Result {
id: String,
content: String,
#[serde(default = "result_ok_default")]
ok: bool,
},
}
fn result_ok_default() -> bool {
true
}
impl FixtureEntry {
pub(super) fn into_message(self) -> Message {
match self {
Self::User(text) => Message::User(vec![ContentBlock::Text(text)]),
Self::Call {
id,
name,
arguments,
} => Message::Assistant(vec![ContentBlock::ToolCall(ToolCall {
id,
name,
arguments,
})]),
Self::Result { id, content, ok } => Message::ToolResult(ToolResult { id, ok, content }),
}
}
}
#[derive(Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub(super) enum FixturePending {
Process {
command: String,
},
Write {
path: String,
#[serde(default)]
content: String,
},
Read {
path: String,
},
}
impl FixturePending {
pub(super) fn into_request(
self,
workspace: &Path,
) -> (&'static str, Value, CapabilityRequest, String) {
match self {
Self::Process { command } => (
"bash",
json!({ "command": command }),
process_request(workspace, &command),
command,
),
Self::Write { path, content } => {
let (request, summary) = path_request(PathAccess::Write, workspace, &path);
(
"write",
json!({ "path": path, "content": content }),
request,
summary,
)
}
Self::Read { path } => {
let (request, summary) = path_request(PathAccess::Read, workspace, &path);
("read_file", json!({ "path": path }), request, summary)
}
}
}
}