use car_ir::{ActionProposal, ActionResult, ActionStatus, ActionType};
use car_proto::{CliOutcome, PolicyRejection, RunRecord, RunTurn, VerifierVerdict};
use serde_json::Value;
use std::collections::HashMap;
const DRIVE_CLI_TOOL: &str = "drive_cli";
const CHECK_OUTCOME_TOOL: &str = "check_outcome";
pub fn record_turns(
proposal: &ActionProposal,
results: &[ActionResult],
start_index: usize,
) -> Vec<RunRecord> {
let by_id: HashMap<&str, &ActionResult> =
results.iter().map(|r| (r.action_id.as_str(), r)).collect();
proposal
.actions
.iter()
.enumerate()
.map(|(offset, action)| {
let result = by_id.get(action.id.as_str()).copied();
RunRecord::Turn(turn_for(action, result, start_index + offset))
})
.collect()
}
fn turn_for(action: &car_ir::Action, result: Option<&ActionResult>, index: usize) -> RunTurn {
let tool = action.tool.clone();
let prompt = action
.parameters
.get("prompt")
.and_then(Value::as_str)
.map(str::to_string);
let parameters = serde_json::to_value(&action.parameters).unwrap_or(Value::Null);
let output = result.and_then(|r| r.output.clone());
if let Some(res) = result {
if res.status == ActionStatus::Rejected {
return RunTurn {
index,
prompt,
tool,
parameters,
output,
cli_outcome: None,
verifier_verdict: VerifierVerdict::NotRun,
policy_rejected: Some(parse_rejection(res.error.as_deref())),
};
}
}
let is_tool_call = action.action_type == ActionType::ToolCall;
let (cli_outcome, verifier_verdict) = match (is_tool_call, tool.as_deref(), output.as_ref()) {
(true, Some(DRIVE_CLI_TOOL), out) => (classify_cli_outcome(out), VerifierVerdict::NotRun),
(true, Some(CHECK_OUTCOME_TOOL), out) => (None, classify_verifier(out)),
_ => (None, VerifierVerdict::NotRun),
};
RunTurn {
index,
prompt,
tool,
parameters,
output,
cli_outcome,
verifier_verdict,
policy_rejected: None,
}
}
fn classify_cli_outcome(output: Option<&Value>) -> Option<CliOutcome> {
let out = output?;
if out.get("timed_out").and_then(Value::as_bool) == Some(true) {
return Some(CliOutcome::Timeout);
}
if let Some(code) = out.get("exit_code").and_then(Value::as_i64) {
return Some(CliOutcome::Exited { code });
}
if out
.get("signal")
.map(|v| !v.is_null())
.unwrap_or(false)
{
return Some(CliOutcome::Killed);
}
None
}
fn classify_verifier(output: Option<&Value>) -> VerifierVerdict {
match output.and_then(|o| o.get("passed")).and_then(Value::as_bool) {
Some(true) => VerifierVerdict::Pass,
Some(false) => VerifierVerdict::Fail,
None => VerifierVerdict::NotRun,
}
}
fn parse_rejection(error: Option<&str>) -> PolicyRejection {
let rule = error.unwrap_or("").to_string();
PolicyRejection {
param: extract_param(&rule),
rule,
}
}
fn extract_param(reason: &str) -> Option<String> {
let marker = "param '";
let start = reason.find(marker)? + marker.len();
let rest = &reason[start..];
let end = rest.find('\'')?;
let name = &rest[..end];
if name.is_empty() {
None
} else {
Some(name.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use car_ir::Action;
use serde_json::json;
use std::collections::HashMap;
fn drive_action(id: &str, prompt: &str) -> Action {
Action {
id: id.to_string(),
action_type: ActionType::ToolCall,
tool: Some(DRIVE_CLI_TOOL.to_string()),
parameters: [
("cli".to_string(), json!("claude")),
("prompt".to_string(), json!(prompt)),
]
.into_iter()
.collect(),
preconditions: vec![],
expected_effects: HashMap::new(),
state_dependencies: vec![],
read_set: vec![],
write_set: vec![],
assumptions: vec![],
idempotent: false,
max_retries: 3,
failure_behavior: car_ir::FailureBehavior::Abort,
timeout_ms: None,
metadata: HashMap::new(),
}
}
fn check_action(id: &str, command: &str) -> Action {
Action {
id: id.to_string(),
action_type: ActionType::ToolCall,
tool: Some(CHECK_OUTCOME_TOOL.to_string()),
parameters: [("command".to_string(), json!(command))]
.into_iter()
.collect(),
preconditions: vec![],
expected_effects: HashMap::new(),
state_dependencies: vec![],
read_set: vec![],
write_set: vec![],
assumptions: vec![],
idempotent: false,
max_retries: 3,
failure_behavior: car_ir::FailureBehavior::Abort,
timeout_ms: None,
metadata: HashMap::new(),
}
}
fn proposal(actions: Vec<Action>) -> ActionProposal {
ActionProposal {
id: "p1".to_string(),
source: "test".to_string(),
actions,
timestamp: chrono::Utc::now(),
context: HashMap::new(),
}
}
fn result(id: &str, status: ActionStatus, output: Option<Value>, error: Option<&str>) -> ActionResult {
ActionResult {
action_id: id.to_string(),
status,
output,
error: error.map(str::to_string),
state_changes: HashMap::new(),
duration_ms: None,
timestamp: chrono::Utc::now(),
}
}
fn turn(rec: &RunRecord) -> &RunTurn {
match rec {
RunRecord::Turn(t) => t,
other => panic!("expected Turn, got {other:?}"),
}
}
#[test]
fn drive_cli_turn_records_prompt_output_and_exit_code() {
let prop = proposal(vec![drive_action("a1", "make the test pass")]);
let out = json!({ "cli": "claude", "exit_code": 0, "output_tail": "all green" });
let results = vec![result("a1", ActionStatus::Succeeded, Some(out.clone()), None)];
let recs = record_turns(&prop, &results, 0);
assert_eq!(recs.len(), 1);
let t = turn(&recs[0]);
assert_eq!(t.index, 0);
assert_eq!(t.prompt.as_deref(), Some("make the test pass"));
assert_eq!(t.tool.as_deref(), Some(DRIVE_CLI_TOOL));
assert_eq!(t.cli_outcome, Some(CliOutcome::Exited { code: 0 }));
assert_eq!(t.verifier_verdict, VerifierVerdict::NotRun);
assert!(t.policy_rejected.is_none());
assert_eq!(
t.output.as_ref().unwrap().get("output_tail").unwrap(),
&json!("all green")
);
}
#[test]
fn check_outcome_pass_and_fail() {
let prop = proposal(vec![check_action("v1", "test -f built")]);
let pass = vec![result(
"v1",
ActionStatus::Succeeded,
Some(json!({ "passed": true, "output_tail": "ok" })),
None,
)];
let pass_recs = record_turns(&prop, &pass, 0);
assert_eq!(turn(&pass_recs[0]).verifier_verdict, VerifierVerdict::Pass);
let fail = vec![result(
"v1",
ActionStatus::Succeeded,
Some(json!({ "passed": false, "output_tail": "still failing" })),
None,
)];
let fail_recs = record_turns(&prop, &fail, 0);
let t = turn(&fail_recs[0]);
assert_eq!(t.verifier_verdict, VerifierVerdict::Fail);
assert!(t.cli_outcome.is_none());
}
#[test]
fn policy_rejected_drive_records_rejection_and_not_run() {
let prop = proposal(vec![drive_action("a1", "rm -rf /")]);
let results = vec![result(
"a1",
ActionStatus::Rejected,
None,
Some("policy 'no-destructive': param 'prompt' matches 'rm -rf'"),
)];
let recs = record_turns(&prop, &results, 0);
let t = turn(&recs[0]);
let pr = t.policy_rejected.as_ref().expect("rejection recorded");
assert!(pr.rule.contains("no-destructive"));
assert_eq!(pr.param.as_deref(), Some("prompt"));
assert_eq!(t.cli_outcome, None);
assert_eq!(t.verifier_verdict, VerifierVerdict::NotRun);
assert!(t.output.is_none());
}
#[test]
fn timed_out_drive_records_timeout_and_not_run() {
let prop = proposal(vec![drive_action("a1", "long task")]);
let out = json!({ "timed_out": true, "exit_code": null, "output_tail": "..." });
let results = vec![result("a1", ActionStatus::Succeeded, Some(out), None)];
let recs = record_turns(&prop, &results, 0);
let t = turn(&recs[0]);
assert_eq!(t.cli_outcome, Some(CliOutcome::Timeout));
assert_eq!(t.verifier_verdict, VerifierVerdict::NotRun);
}
#[test]
fn killed_drive_records_killed() {
let prop = proposal(vec![drive_action("a1", "task")]);
let out = json!({ "timed_out": false, "exit_code": null, "signal": "SIGKILL" });
let results = vec![result("a1", ActionStatus::Succeeded, Some(out), None)];
let recs = record_turns(&prop, &results, 0);
assert_eq!(turn(&recs[0]).cli_outcome, Some(CliOutcome::Killed));
}
#[test]
fn generic_tool_records_tool_params_output() {
let mut action = drive_action("a1", "ignored");
action.tool = Some("search".to_string());
action.parameters = [("query".to_string(), json!("rust"))].into_iter().collect();
let prop = proposal(vec![action]);
let results = vec![result(
"a1",
ActionStatus::Succeeded,
Some(json!("results")),
None,
)];
let recs = record_turns(&prop, &results, 0);
let t = turn(&recs[0]);
assert_eq!(t.tool.as_deref(), Some("search"));
assert_eq!(t.parameters.get("query").unwrap(), &json!("rust"));
assert_eq!(t.output, Some(json!("results")));
assert_eq!(t.cli_outcome, None);
assert_eq!(t.verifier_verdict, VerifierVerdict::NotRun);
assert!(t.policy_rejected.is_none());
}
#[test]
fn join_is_by_action_id_not_position() {
let prop = proposal(vec![
drive_action("a1", "first"),
check_action("a2", "verify"),
]);
let results = vec![
result("a2", ActionStatus::Succeeded, Some(json!({ "passed": true })), None),
result(
"a1",
ActionStatus::Succeeded,
Some(json!({ "exit_code": 0 })),
None,
),
];
let recs = record_turns(&prop, &results, 0);
let t0 = turn(&recs[0]);
assert_eq!(t0.tool.as_deref(), Some(DRIVE_CLI_TOOL));
assert_eq!(t0.cli_outcome, Some(CliOutcome::Exited { code: 0 }));
let t1 = turn(&recs[1]);
assert_eq!(t1.tool.as_deref(), Some(CHECK_OUTCOME_TOOL));
assert_eq!(t1.verifier_verdict, VerifierVerdict::Pass);
}
#[test]
fn index_is_monotonic_across_proposals() {
let prop = proposal(vec![drive_action("a1", "x")]);
let results = vec![result("a1", ActionStatus::Succeeded, Some(json!({ "exit_code": 0 })), None)];
let recs = record_turns(&prop, &results, 1);
assert_eq!(turn(&recs[0]).index, 1);
}
#[test]
fn extract_param_handles_missing_token() {
assert_eq!(extract_param("policy 'x': tool 'deploy' denied"), None);
assert_eq!(
extract_param("policy 'x': param 'prompt' matches 'rm'"),
Some("prompt".to_string())
);
assert_eq!(extract_param("param ''"), None);
}
}