use std::path::Path;
use std::process::Command;
use oxicode_ai::Message;
#[derive(Debug, Clone)]
pub struct GitState {
pub branch: Option<String>,
pub recent_commits: Vec<String>,
pub modified_files: Vec<String>,
}
pub fn gather_git_state(cwd: &str) -> GitState {
let branch =
git_output(cwd, &["rev-parse", "--abbrev-ref", "HEAD"]).map(|s| s.trim().to_string());
let recent_commits = git_output(cwd, &["log", "--oneline", "-5"])
.map(|s| {
s.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect()
})
.unwrap_or_default();
let modified_files = git_output(cwd, &["status", "--porcelain"])
.map(|s| {
s.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect()
})
.unwrap_or_default();
GitState {
branch,
recent_commits,
modified_files,
}
}
fn git_output(cwd: &str, args: &[&str]) -> Option<String> {
Command::new("git")
.args(args)
.current_dir(cwd)
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
}
#[derive(Debug, Clone)]
pub struct HandoffChain {
pub seq: usize,
pub prev_path: Option<String>,
}
pub fn detect_handoff_chain(handoffs_dir: &Path) -> HandoffChain {
let mut entries: Vec<(i64, std::path::PathBuf)> = Vec::new();
if let Ok(rd) = std::fs::read_dir(handoffs_dir) {
for entry in rd.flatten() {
let path = entry.path();
if path.extension().is_some_and(|e| e == "md")
&& let Ok(meta) = entry.metadata()
{
let mtime_ms = meta
.modified()
.unwrap_or(std::time::UNIX_EPOCH)
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
entries.push((mtime_ms, path));
}
}
}
if entries.is_empty() {
return HandoffChain {
seq: 1,
prev_path: None,
};
}
entries.sort_by_key(|&(ms, _)| std::cmp::Reverse(ms));
let latest = &entries[0].1;
let prev_seq = std::fs::read_to_string(latest)
.ok()
.and_then(|content| {
content
.lines()
.find_map(|l| l.strip_prefix("> **Chain:** #"))
.and_then(|rest| rest.split_whitespace().next())
.and_then(|n| n.parse::<usize>().ok())
})
.unwrap_or(0);
HandoffChain {
seq: prev_seq + 1,
prev_path: latest.to_str().map(|s| s.to_string()),
}
}
pub fn handoff_system_prompt() -> &'static str {
"You are a session-handoff document generator for an AI coding agent. \
Your job is to mine a conversation and produce a structured markdown \
document that lets a fresh agent continue the work with zero ambiguity."
}
const MAX_MSG_CHARS: usize = 3000;
pub fn build_handoff_prompt(
messages: &[Message],
git_state: &GitState,
chain: &HandoffChain,
) -> String {
let mut prompt = String::with_capacity(8 * 1024);
prompt.push_str(INSTRUCTIONS);
if let Some(prev) = &chain.prev_path {
prompt.push_str(&format!(
"\n**Chain context:** This is handoff #{} in a chain. \
The previous handoff is at `{}`. Reference it if the current \
conversation builds on earlier work.\n",
chain.seq, prev
));
}
prompt.push_str("\n## Git State\n\n");
if let Some(branch) = &git_state.branch {
prompt.push_str(&format!("**Branch:** `{}`\n\n", branch));
} else {
prompt.push_str("(not a git repository)\n\n");
}
if !git_state.recent_commits.is_empty() {
prompt.push_str("**Recent commits:**\n```\n");
for commit in &git_state.recent_commits {
prompt.push_str(commit);
prompt.push('\n');
}
prompt.push_str("```\n\n");
}
if !git_state.modified_files.is_empty() {
prompt.push_str("**Modified files (uncommitted):**\n```\n");
for f in &git_state.modified_files {
prompt.push_str(f);
prompt.push('\n');
}
prompt.push_str("```\n\n");
}
prompt.push_str("## Conversation\n\n");
prompt.push_str("Below is the full conversation to mine for the handoff:\n\n");
for msg in messages {
let role = match msg {
Message::User(_) => "User",
Message::Assistant(_) => "Assistant",
Message::ToolResult(_) => "Tool",
};
let content = msg.text_content().unwrap_or_default();
let content = truncate_msg(&content, MAX_MSG_CHARS);
prompt.push_str(&format!("[{}]: {}\n\n", role, content));
}
prompt.push_str("---\n\n");
prompt.push_str(OUTPUT_TEMPLATE);
prompt
}
fn truncate_msg(s: &str, max: usize) -> String {
if s.len() <= max {
return s.to_string();
}
let boundary = s
.char_indices()
.take_while(|(i, _)| *i <= max)
.last()
.map(|(i, c)| i + c.len_utf8())
.unwrap_or(max);
format!("{}... [truncated]", &s[..boundary])
}
const INSTRUCTIONS: &str = "\
You are writing a **session handoff document** — a structured markdown file that \
captures everything a fresh AI agent needs to continue the current work without \
re-exploring or re-discovering what was already done.
Read the conversation below carefully and produce the handoff document following \
the output template at the end of this message.
**Critical guidelines:**
1. **\"What Was Tried\" is the most valuable section.** Capture every approach \
attempted — especially failures and abandoned approaches. These are the most \
expensive things to rediscover. Include what was tried, what happened, and why \
it was kept or abandoned.
2. **\"Remaining Work\" must be concrete and actionable.** Not vague goals like \
\"improve performance\" — specific tasks like \"Add retry logic to the HTTP client \
in `src/client.rs:45`\". Order them by priority.
3. **\"Key Decisions\" should record what was chosen AND what was rejected, with \
reasoning.** Future sessions need to understand why, not just what.
4. **Include specific file paths and line numbers** wherever relevant.
5. **Be selective.** Exclude conversational filler, repetitions, and digressions. \
Include only what matters for continuing the work.
6. **Write the document as if the reader has zero context** — because they do.";
const OUTPUT_TEMPLATE: &str = "\
## Output Template
Produce the handoff document in this exact structure (fill in all sections; \
omit a section only if truly nothing applies):
```markdown
# Session Handoff — {brief title}
## Goal
{What we're working on and why — 1-2 paragraphs}
## Current State
{Bullet list of concrete accomplishments and current state of work}
## What Was Tried
{Every approach attempted, including failures. This is the most valuable section.}
### {Approach title}
- **What:** what was attempted
- **Result:** what happened
- **Verdict:** kept | abandoned (and why)
## Key Decisions
{Architectural choices with rationale. What was chosen AND what was rejected.}
## Remaining Work
1. {First next step — concrete, with file paths if relevant}
2. {Second next step}
3. ...
## Critical Files
| Path | Role |
|------|------|
| `path/to/file` | what it does and why it matters |
## Gotchas & Risks
- {Known issues, pitfalls, things to watch out for}
```
Write the complete handoff document now. Output ONLY the markdown document — \
no preamble, no explanation, no closing remarks.";
#[cfg(test)]
mod tests {
use super::*;
use oxicode_ai::Message;
fn user_msg(text: &str) -> Message {
Message::user(text)
}
#[test]
fn build_handoff_prompt_includes_instructions() {
let messages = vec![user_msg("hello")];
let git_state = GitState {
branch: Some("main".into()),
recent_commits: vec!["abc1234 init".into()],
modified_files: vec![],
};
let chain = HandoffChain {
seq: 1,
prev_path: None,
};
let prompt = build_handoff_prompt(&messages, &git_state, &chain);
assert!(prompt.contains("session handoff document"));
assert!(prompt.contains("What Was Tried"));
assert!(prompt.contains("[User]: hello"));
assert!(prompt.contains("Branch"));
assert!(prompt.contains("main"));
}
#[test]
fn build_handoff_prompt_includes_chain_link() {
let messages = vec![user_msg("test")];
let git_state = GitState {
branch: None,
recent_commits: vec![],
modified_files: vec![],
};
let chain = HandoffChain {
seq: 3,
prev_path: Some(".oxicode/handoffs/prev.md".into()),
};
let prompt = build_handoff_prompt(&messages, &git_state, &chain);
assert!(prompt.contains("handoff #3"));
assert!(prompt.contains("prev.md"));
}
#[test]
fn truncate_msg_short_unchanged() {
assert_eq!(truncate_msg("hello", 100), "hello");
}
#[test]
fn truncate_msg_long_truncated() {
let long = "x".repeat(5000);
let result = truncate_msg(&long, 100);
assert!(result.ends_with("[truncated]"));
assert!(result.len() < long.len());
}
#[test]
fn detect_chain_empty_dir() {
let tmp = tempfile::tempdir().unwrap();
let chain = detect_handoff_chain(tmp.path());
assert_eq!(chain.seq, 1);
assert!(chain.prev_path.is_none());
}
#[test]
fn detect_chain_with_existing() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("2026-08-12-100000-test.md");
std::fs::write(&path, "# Handoff\n\n> **Chain:** #2\n").unwrap();
let chain = detect_handoff_chain(tmp.path());
assert_eq!(chain.seq, 3);
assert!(chain.prev_path.is_some());
}
#[test]
fn gather_git_state_in_temp_dir() {
let tmp = tempfile::tempdir().unwrap();
let state = gather_git_state(tmp.path().to_str().unwrap());
assert!(state.branch.is_none());
}
}