use std::path::PathBuf;
use anyhow::{Context as _, Result};
use oxicode_ai::{Context, Message, StreamOptions, UserMessage, complete};
use crate::app::agent_session::AgentSessionHandle;
use crate::prompt::handoff_prompt::{
build_handoff_prompt, detect_handoff_chain, gather_git_state, handoff_system_prompt,
};
const HANDOFFS_DIR: &str = ".oxicode/handoffs";
#[derive(Debug, Clone)]
pub struct HandoffOptions {
pub slug: Option<String>,
pub auto_continue: bool,
pub dry_run: bool,
}
impl Default for HandoffOptions {
fn default() -> Self {
Self {
slug: None,
auto_continue: true,
dry_run: false,
}
}
}
pub async fn generate_and_apply_handoff(
session: &AgentSessionHandle,
opts: &HandoffOptions,
) -> Result<String> {
let messages = session.messages();
let model_id = session.model_id();
let cwd = session.cwd().to_string();
let git_state = gather_git_state(&cwd);
let handoffs_dir = PathBuf::from(&cwd).join(HANDOFFS_DIR);
let chain = detect_handoff_chain(&handoffs_dir);
let prompt = build_handoff_prompt(&messages, &git_state, &chain);
let model = oxicode_agent::model_id::resolve_model_from_id(&model_id).context(format!(
"Failed to resolve model '{model_id}' for handoff generation"
))?;
let mut llm_context = Context::new();
llm_context.set_system_prompt(handoff_system_prompt());
llm_context.add_message(Message::User(UserMessage::new(prompt)));
let llm_options = StreamOptions {
temperature: Some(0.3),
max_tokens: Some(4096),
..Default::default()
};
let result = complete(&model, &llm_context, Some(llm_options))
.await
.context("Handoff LLM call failed")?;
let doc_content = result.text_content();
let slug = opts.slug.clone().unwrap_or_else(|| derive_slug(&messages));
let doc_path = write_handoff_doc(&cwd, &doc_content, &slug, &chain)?;
if !opts.dry_run {
session.start_new_session();
session.emit_handoff_complete(doc_path.clone(), opts.auto_continue);
}
Ok(doc_path)
}
fn write_handoff_doc(
cwd: &str,
content: &str,
slug: &str,
chain: &crate::prompt::handoff_prompt::HandoffChain,
) -> Result<String> {
let handoffs_dir = PathBuf::from(cwd).join(HANDOFFS_DIR);
std::fs::create_dir_all(&handoffs_dir).context("Failed to create handoffs directory")?;
let timestamp = chrono::Utc::now().format("%Y-%m-%d-%H%M%S");
let filename = format!("{}-{}.md", timestamp, slug);
let path = handoffs_dir.join(&filename);
let prev_link = chain
.prev_path
.as_ref()
.map(|p| format!(" (continues from {})", p))
.unwrap_or_default();
let full_content = format!("> **Chain:** #{}{}\n\n{}", chain.seq, prev_link, content);
std::fs::write(&path, &full_content).context("Failed to write handoff document")?;
Ok(path.to_string_lossy().into_owned())
}
fn derive_slug(messages: &[Message]) -> String {
let first_user = messages
.iter()
.find(|m| matches!(m, Message::User(_)))
.map(|m| m.text_content().unwrap_or_default())
.unwrap_or_else(|| "session".to_string());
let slug: String = first_user
.lines()
.next()
.unwrap_or("session")
.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect();
let slug: String = slug
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-");
if slug.is_empty() {
"session".to_string()
} else {
slug.trim_start_matches('-').chars().take(40).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn derive_slug_basic() {
let msgs = vec![Message::user("Fix the authentication bug in login")];
let slug = derive_slug(&msgs);
assert_eq!(slug, "fix-the-authentication-bug-in-login");
}
#[test]
fn derive_slug_truncates() {
let long = "x".repeat(100);
let msgs = vec![Message::user(long)];
let slug = derive_slug(&msgs);
assert!(slug.len() <= 40);
}
#[test]
fn derive_slug_no_messages() {
let slug = derive_slug(&[]);
assert_eq!(slug, "session");
}
#[test]
fn derive_slug_special_chars() {
let msgs = vec![Message::user("Hello, World!!! @#$%")];
let slug = derive_slug(&msgs);
assert_eq!(slug, "hello-world");
}
#[test]
fn derive_slug_leading_hyphen() {
let msgs = vec![Message::user("---fix-bug")];
let slug = derive_slug(&msgs);
assert_eq!(slug, "fix-bug");
assert!(!slug.starts_with('-'));
}
#[test]
fn write_handoff_doc_creates_file() {
let tmp = tempfile::tempdir().unwrap();
let cwd = tmp.path().to_str().unwrap();
let chain = crate::prompt::handoff_prompt::HandoffChain {
seq: 1,
prev_path: None,
};
let path =
write_handoff_doc(cwd, "# Session Handoff — test\n\nbody", "test", &chain).unwrap();
assert!(std::path::Path::new(&path).exists());
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("Session Handoff"));
}
}