use crate::generative_model::{Content, Message};
use crate::session::{Session, SessionKind};
#[derive(Debug, Clone)]
pub struct CompactOptions {
pub tail_user_turns: usize,
pub tail_tool_body_max_chars: usize,
}
impl Default for CompactOptions {
fn default() -> Self {
Self {
tail_user_turns: 2,
tail_tool_body_max_chars: 4_000,
}
}
}
#[derive(Debug, Clone)]
pub struct CompactOutcome {
pub predecessor_id: String,
pub successor_id: String,
pub summary_path: std::path::PathBuf,
pub tail_messages: usize,
}
pub fn compact_session(
predecessor: &Session,
summary_markdown: &str,
model: &str,
opts: &CompactOptions,
) -> Result<(Session, CompactOutcome), String> {
if predecessor.messages.is_empty() {
return Err("cannot compact an empty session".into());
}
if summary_markdown.trim().is_empty() {
return Err("summary markdown is empty".into());
}
let tail = select_tail(
&predecessor.messages,
opts.tail_user_turns,
opts.tail_tool_body_max_chars,
);
let mut successor = Session::new(model);
successor.title = predecessor.title.clone();
successor.links = predecessor.links.clone();
successor.scratchpad = predecessor.scratchpad.clone();
successor.predecessor_id = Some(predecessor.id.clone());
successor.kind = SessionKind::User;
let mut resume = String::from("# Compaction resume\n\n");
resume.push_str(summary_markdown.trim());
resume.push_str(&format!(
"\n\n---\nPredecessor session: `{}`\nSummary file: `{}`\n",
predecessor.id,
predecessor.summary_path().display()
));
let mut messages = vec![Message::UserMessage {
content: vec![Content::Text { text: resume }],
}];
messages.extend(tail.iter().cloned());
let tail_messages = messages.len().saturating_sub(1);
successor.messages = messages;
let summary_path = predecessor.summary_path();
if let Some(parent) = summary_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
crate::session::atomically_write(summary_path.as_path(), summary_markdown.as_bytes())?;
let outcome = CompactOutcome {
predecessor_id: predecessor.id.clone(),
successor_id: successor.id.clone(),
summary_path,
tail_messages,
};
Ok((successor, outcome))
}
pub fn link_compact_pair(predecessor: &mut Session, successor: &Session) -> Result<(), String> {
predecessor.successor_id = Some(successor.id.clone());
predecessor.touch();
predecessor.save()?;
successor.save()?;
Ok(())
}
pub fn select_tail(messages: &[Message], user_turns: usize, tool_body_max: usize) -> Vec<Message> {
if user_turns == 0 || messages.is_empty() {
return Vec::new();
}
let user_idxs: Vec<usize> = messages
.iter()
.enumerate()
.filter_map(|(i, m)| matches!(m, Message::UserMessage { .. }).then_some(i))
.collect();
if user_idxs.is_empty() {
return Vec::new();
}
let start_user = user_idxs.len().saturating_sub(user_turns);
let start = user_idxs[start_user];
let slice = &messages[start..];
let mut end = slice.len();
if let Some(Message::AssistantMessage { tool_uses, .. }) = slice.last()
&& !tool_uses.is_empty()
{
end = end.saturating_sub(1);
}
let mut out: Vec<Message> = slice[..end].to_vec();
for m in &mut out {
truncate_message_bodies(m, tool_body_max);
}
out
}
fn truncate_message_bodies(msg: &mut Message, max_chars: usize) {
match msg {
Message::ToolResults { tool_use_results } => {
for r in tool_use_results {
for c in &mut r.content {
if let Content::Text { text } = c {
*text = truncate_chars(text, max_chars);
}
}
}
}
Message::AssistantMessage { content, .. } | Message::UserMessage { content } => {
for c in content {
if let Content::Text { text } = c {
*text = truncate_chars(text, max_chars.max(8_000));
}
}
}
}
}
fn truncate_chars(s: &str, max_chars: usize) -> String {
if s.chars().count() <= max_chars {
return s.to_string();
}
let t: String = s.chars().take(max_chars.saturating_sub(20)).collect();
format!("{t}\n…(truncated for compact tail)")
}
pub fn compact_subagent_prompt(predecessor_id: &str) -> String {
format!(
r#"You are a compaction worker. Explore session `{predecessor_id}` with the `session_history` tool (stats, search, range, expand). Do NOT use bash to read session JSON.
Write a concise markdown summary via `session_history` action `write_summary` for that same session_id. The summary MUST use these headings:
# Goal / active task
# Decisions
# Key paths
# Todos / open work
# Constraints
# Recent outcome
Rules:
- Prefer absolute paths, hosts, branch names, PR links, and concrete decisions.
- Drop raw tool stdout and exploratory dead-ends unless they constrain next steps.
- Keep the whole summary under ~1500 tokens.
- After write_summary succeeds, reply with only: SUMMARY_OK path=<path from tool>
"#
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::generative_model::{ToolResult, ToolUse, TurnEndReason};
use crate::session::uuid_simple_hex;
use serde_json::json;
fn user(text: &str) -> Message {
Message::UserMessage {
content: vec![Content::Text { text: text.into() }],
}
}
fn assistant_end(text: &str) -> Message {
Message::AssistantMessage {
content: vec![Content::Text { text: text.into() }],
tool_uses: vec![],
turn_end_reason: Some(TurnEndReason::EndTurn),
}
}
fn assistant_tools() -> Message {
Message::AssistantMessage {
content: vec![],
tool_uses: vec![ToolUse {
id: "t1".into(),
name: "bash".into(),
input: json!({"command": "echo hi"}),
}],
turn_end_reason: Some(TurnEndReason::ToolUse),
}
}
fn tool_results() -> Message {
Message::ToolResults {
tool_use_results: vec![ToolResult {
id: "t1".into(),
content: vec![Content::Text {
text: "hi\n".into(),
}],
is_error: false,
}],
}
}
#[test]
fn select_tail_keeps_last_user_turns_and_tool_loop() {
let messages = vec![
user("old"),
assistant_end("old a"),
user("mid"),
assistant_tools(),
tool_results(),
assistant_end("mid a"),
user("new"),
assistant_end("new a"),
];
let tail = select_tail(&messages, 2, 1000);
assert!(matches!(tail[0], Message::UserMessage { .. }));
assert!(tail.len() >= 5, "tail={tail:?}");
assert!(matches!(
tail.last(),
Some(Message::AssistantMessage { .. })
));
}
#[test]
fn select_tail_drops_trailing_incomplete_tool_use() {
let messages = vec![user("u"), assistant_tools()];
let tail = select_tail(&messages, 1, 1000);
assert_eq!(tail.len(), 1);
assert!(matches!(tail[0], Message::UserMessage { .. }));
}
#[test]
fn compact_session_seeds_resume_and_links() {
let _guard = crate::session::lock_myco_home_for_test();
let dir = std::env::temp_dir().join(format!(
"myco-compact-{}",
uuid_simple_hex(uuid::Uuid::new_v4())
));
std::fs::create_dir_all(&dir).unwrap();
unsafe {
std::env::set_var("MYCO_HOME", &dir);
}
let mut pred = Session::new("claude-haiku-4-5");
pred.messages = vec![user("hello"), assistant_end("world")];
pred.title = Some("t".into());
pred.save().unwrap();
let (succ, out) = compact_session(
&pred,
"## Goal\nDo the thing\n",
"claude-haiku-4-5",
&CompactOptions::default(),
)
.unwrap();
assert_eq!(out.predecessor_id, pred.id);
assert_eq!(succ.predecessor_id.as_deref(), Some(pred.id.as_str()));
assert!(matches!(succ.messages[0], Message::UserMessage { .. }));
assert!(
std::fs::read_to_string(pred.summary_path())
.unwrap()
.contains("Do the thing")
);
let _ = std::fs::remove_dir_all(&dir);
unsafe {
std::env::remove_var("MYCO_HOME");
}
}
}