use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum BoundaryStatus {
Done,
InProgress,
NotStarted,
Blocked,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BoundaryItem {
pub feature: String,
pub status: BoundaryStatus,
#[serde(default)]
pub notes: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HandoffRequest {
pub target_agent: String,
pub state_summary: String,
#[serde(default)]
pub modified_files: Vec<String>,
#[serde(default)]
pub open_decisions: Vec<String>,
pub task_context: String,
#[serde(default)]
pub context_files: Vec<String>,
#[serde(default)]
pub test_results: Option<String>,
#[serde(default)]
pub boundary_status: Vec<BoundaryItem>,
#[serde(default)]
pub known_issues: Vec<String>,
#[serde(default)]
pub next_actions: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HandoffReceipt {
pub accepted: bool,
#[serde(default)]
pub continuation_message: String,
#[serde(default)]
pub revised_plan: Option<String>,
}
impl HandoffRequest {
pub fn to_handoff_prompt(&self) -> String {
let mut parts = vec![
"## HANDOFF FROM PREVIOUS AGENT".to_string(),
String::new(),
"Previous agent completed work and is handing off to you.".to_string(),
String::new(),
format!("### Current State\n{}", self.state_summary),
];
if !self.boundary_status.is_empty() {
let items: Vec<String> = self
.boundary_status
.iter()
.map(|item| {
let status_str = match item.status {
BoundaryStatus::Done => "DONE",
BoundaryStatus::InProgress => "IN PROGRESS",
BoundaryStatus::NotStarted => "NOT STARTED",
BoundaryStatus::Blocked => "BLOCKED",
};
match &item.notes {
Some(notes) => format!("- [{}] {} -- {}", status_str, item.feature, notes),
None => format!("- [{}] {}", status_str, item.feature),
}
})
.collect();
parts.push(format!("### Boundary Status\n{}", items.join("\n")));
}
if !self.modified_files.is_empty() {
parts.push(format!("### Modified Files\n{}", self.modified_files.join("\n")));
}
if let Some(test_results) = &self.test_results {
parts.push(format!("### Test Results\n{test_results}"));
}
if !self.open_decisions.is_empty() {
parts.push(format!("### Open Decisions\n{}", self.open_decisions.join("\n")));
}
if !self.known_issues.is_empty() {
parts.push(format!(
"### Known Issues\n{}",
self.known_issues
.iter()
.map(|i| format!("- {i}"))
.collect::<Vec<_>>()
.join("\n")
));
}
if !self.next_actions.is_empty() {
parts.push(format!(
"### Recommended Next Actions\n{}",
self.next_actions
.iter()
.map(|a| format!("1. {a}"))
.collect::<Vec<_>>()
.join("\n")
));
}
parts.push(format!(
"### Task Context\n{}\n\n\
Continue from where the previous agent left off. \
Read the modified files to understand the current state. \
Check boundary status to know what's done vs. what needs work.",
self.task_context
));
parts.join("\n\n")
}
}
#[cfg(test)]
mod tests {
use super::*;
fn minimal_request() -> HandoffRequest {
HandoffRequest {
target_agent: "coder".into(),
state_summary: "Implemented the login feature".into(),
modified_files: vec!["src/auth.rs".into()],
open_decisions: vec!["Should we use JWT?".into()],
task_context: "Build authentication system".into(),
context_files: vec![],
test_results: None,
boundary_status: vec![],
known_issues: vec![],
next_actions: vec![],
}
}
#[test]
fn handoff_request_round_trip() {
let request = minimal_request();
let json = serde_json::to_string(&request).expect("serialize");
let deserialized: HandoffRequest = serde_json::from_str(&json).expect("deserialize");
assert_eq!(deserialized.target_agent, "coder");
assert_eq!(deserialized.state_summary, "Implemented the login feature");
}
#[test]
fn handoff_request_round_trip_with_new_fields() {
let request = HandoffRequest {
target_agent: "verifier".into(),
state_summary: "All features implemented".into(),
modified_files: vec!["src/main.rs".into()],
open_decisions: vec![],
task_context: "Review the implementation".into(),
context_files: vec![],
test_results: Some("12 passed, 0 failed".into()),
boundary_status: vec![
BoundaryItem {
feature: "login".into(),
status: BoundaryStatus::Done,
notes: Some("JWT auth working".into()),
},
BoundaryItem {
feature: "signup".into(),
status: BoundaryStatus::InProgress,
notes: None,
},
],
known_issues: vec!["Rate limiting not implemented".into()],
next_actions: vec!["Add rate limiting".into(), "Write integration tests".into()],
};
let json = serde_json::to_string(&request).expect("serialize");
let deserialized: HandoffRequest = serde_json::from_str(&json).expect("deserialize");
assert_eq!(deserialized.test_results.as_deref(), Some("12 passed, 0 failed"));
assert_eq!(deserialized.boundary_status.len(), 2);
assert_eq!(deserialized.boundary_status[0].status, BoundaryStatus::Done);
assert_eq!(deserialized.boundary_status[1].status, BoundaryStatus::InProgress);
assert_eq!(deserialized.known_issues.len(), 1);
assert_eq!(deserialized.next_actions.len(), 2);
}
#[test]
fn handoff_prompt_includes_state() {
let request = minimal_request();
let prompt = request.to_handoff_prompt();
assert!(!prompt.contains("Code is ready for review")); assert!(prompt.contains("src/auth.rs"));
assert!(prompt.contains("Build authentication system"));
assert!(prompt.contains("HANDOFF FROM PREVIOUS AGENT"));
}
#[test]
fn handoff_prompt_includes_boundary_status() {
let request = HandoffRequest {
target_agent: "verifier".into(),
state_summary: "Code ready".into(),
modified_files: vec![],
open_decisions: vec![],
task_context: "Review".into(),
context_files: vec![],
test_results: None,
boundary_status: vec![
BoundaryItem {
feature: "auth".into(),
status: BoundaryStatus::Done,
notes: None,
},
BoundaryItem {
feature: "api".into(),
status: BoundaryStatus::InProgress,
notes: Some("endpoints working, tests pending".into()),
},
],
known_issues: vec![],
next_actions: vec![],
};
let prompt = request.to_handoff_prompt();
assert!(prompt.contains("[DONE] auth"));
assert!(prompt.contains("[IN PROGRESS] api -- endpoints working, tests pending"));
}
#[test]
fn handoff_prompt_includes_test_results() {
let request = HandoffRequest {
target_agent: "verifier".into(),
state_summary: "Done".into(),
modified_files: vec![],
open_decisions: vec![],
task_context: "Review".into(),
context_files: vec![],
test_results: Some(
"15 passed, 2 failed\n FAIL: test_login_rate_limit\n FAIL: test_signup_validation".into(),
),
boundary_status: vec![],
known_issues: vec!["Rate limiting missing".into()],
next_actions: vec!["Fix failing tests".into()],
};
let prompt = request.to_handoff_prompt();
assert!(prompt.contains("### Test Results"));
assert!(prompt.contains("15 passed, 2 failed"));
assert!(prompt.contains("### Known Issues"));
assert!(prompt.contains("- Rate limiting missing"));
assert!(prompt.contains("### Recommended Next Actions"));
assert!(prompt.contains("1. Fix failing tests"));
}
#[test]
fn handoff_prompt_omits_empty_sections() {
let request = minimal_request();
let prompt = request.to_handoff_prompt();
assert!(!prompt.contains("### Boundary Status"));
assert!(!prompt.contains("### Test Results"));
assert!(!prompt.contains("### Known Issues"));
assert!(!prompt.contains("### Recommended Next Actions"));
assert!(prompt.contains("### Current State"));
assert!(prompt.contains("### Modified Files"));
assert!(prompt.contains("### Open Decisions"));
assert!(prompt.contains("### Task Context"));
}
#[test]
fn handoff_receipt_round_trip() {
let receipt = HandoffReceipt {
accepted: true,
continuation_message: "Will review the code".into(),
revised_plan: Some("1. Check auth".into()),
};
let json = serde_json::to_string(&receipt).expect("serialize");
let deserialized: HandoffReceipt = serde_json::from_str(&json).expect("deserialize");
assert!(deserialized.accepted);
assert_eq!(deserialized.continuation_message, "Will review the code");
}
#[test]
fn boundary_status_default_deserialization() {
let json = r#"{
"target_agent": "coder",
"state_summary": "done",
"task_context": "build",
"modified_files": [],
"open_decisions": [],
"context_files": []
}"#;
let request: HandoffRequest = serde_json::from_str(json).expect("deserialize");
assert!(request.test_results.is_none());
assert!(request.boundary_status.is_empty());
assert!(request.known_issues.is_empty());
assert!(request.next_actions.is_empty());
}
}