use headroom_core::transforms;
use crate::{
marker::ccr_marker,
state::{AphroditeState, MarkerEntry, ToolEvent},
};
#[derive(Debug, Default, Clone, Copy)]
pub struct ToolCallMeta<'a> {
pub args_json:Option<&'a serde_json::Value>,
pub status:Option<&'a str>,
pub error_type:Option<&'a str>,
pub error_message:Option<&'a str>,
pub duration_ms:Option<u64>,
}
pub fn compute_hash(content:&str) -> String { headroom_core::ccr::compute_key(content.as_bytes()) }
const ESSENTIAL_TOOLS:&[&str] = &[
"skill_view",
"skills_list",
"skill_manage",
"memory",
"session_search",
"read_file",
"read_terminal",
];
pub fn transform_tool_result(state:&mut AphroditeState, content:&str, tool_name:&str) -> serde_json::Value {
transform_tool_result_inner(state, content, tool_name, None, &ToolCallMeta::default())
}
pub fn transform_tool_result_classified(
state:&mut AphroditeState,
content:&str,
tool_name:&str,
classify:Option<(&str, &str)>,
) -> serde_json::Value {
transform_tool_result_inner(state, content, tool_name, classify, &ToolCallMeta::default())
}
pub fn transform_tool_result_with_meta(
state:&mut AphroditeState,
content:&str,
tool_name:&str,
classify:Option<(&str, &str)>,
meta:&ToolCallMeta,
) -> serde_json::Value {
transform_tool_result_inner(state, content, tool_name, classify, meta)
}
fn record_tool_event_from_meta(state:&mut AphroditeState, tool_name:&str, content:&str, meta:&ToolCallMeta) {
let ok = meta.status.map(|s| s != "error").unwrap_or(true);
let error_sig = if ok {
None
} else {
Some(crate::flow::error_sig(meta.error_type, meta.error_message))
};
let wrote_path = wrote_path_from(tool_name, meta.args_json, content);
let sig = crate::flow::normalize_args_sig(tool_name, meta.args_json);
state.record_tool_event(ToolEvent {
turn:state.turn_counter,
tool:tool_name.to_string(),
sig,
ok,
error_sig,
bytes:content.len(),
wrote_path,
});
}
fn wrote_path_from(tool_name:&str, args:Option<&serde_json::Value>, content:&str) -> Option<String> {
if !matches!(tool_name, "write_file" | "patch") {
return None;
}
if let Some(v) = args {
for key in ["path", "file", "file_path", "filename"] {
if let Some(p) = v.get(key).and_then(|x| x.as_str()) {
if !p.is_empty() {
return Some(p.to_string());
}
}
}
}
extract_file_path(content, tool_name)
}
fn transform_tool_result_inner(
state:&mut AphroditeState,
content:&str,
tool_name:&str,
classify:Option<(&str, &str)>,
meta:&ToolCallMeta,
) -> serde_json::Value {
if !content.is_empty() {
record_tool_event_from_meta(state, tool_name, content, meta);
}
if content.is_empty() {
return serde_json::json!({"status": "ok", "compressed": false, "reason": "empty"});
}
if state.file_tools.contains(&tool_name.to_string()) {
if let Some(path) = extract_file_path(content, tool_name) {
state.record_file(path, tool_name.to_string());
}
}
if ESSENTIAL_TOOLS.contains(&tool_name) {
return serde_json::json!({"status": "ok", "compressed": false, "reason": "essential_tool"});
}
if tool_name.starts_with("aphrodite_") || tool_name.starts_with("headroom") {
return serde_json::json!({"status": "ok", "compressed": false, "reason": "self_tool"});
}
if state.tool_threshold > 0 && content.len() < state.tool_threshold {
return serde_json::json!({"status": "ok", "compressed": false, "reason": "below_threshold"});
}
let (type_str, classify_content):(String, &str) = match classify {
Some((c, t)) => (t.to_string(), c),
None => {
let base = transforms::detect(content).as_str().to_string();
let t = match base.as_str() {
"text" | "log" | "plain" | "" => {
crate::preview::detect_semantic_type(content)
.map(|s| s.to_string())
.unwrap_or(base)
},
_ => base,
};
(t, content)
},
};
let hash = headroom_core::ccr::compute_key(content.as_bytes());
state.inline_store_put(hash.clone(), content.to_string());
let preview = crate::build_preview(&type_str, classify_content);
let marker = ccr_marker(&hash, &type_str, content.len(), &preview, None, None, None);
state.record_marker(MarkerEntry {
hash:hash.clone(),
ccr_type:type_str.clone(),
size:content.len(),
preview:preview.clone(),
turn:state.turn_counter,
center:None,
meta:None,
});
serde_json::json!({
"status": "ok",
"compressed": true,
"hash": hash,
"type": type_str,
"size": content.len(),
"preview": preview,
"marker": marker,
})
}
pub fn transform_terminal_output(state:&mut AphroditeState, content:&str) -> serde_json::Value {
transform_terminal_output_classified(state, content, None)
}
pub fn transform_terminal_output_classified(
state:&mut AphroditeState,
content:&str,
classify:Option<(&str, &str)>,
) -> serde_json::Value {
transform_terminal_output_inner(state, content, classify, None, None)
}
pub fn transform_terminal_output_with_meta(
state:&mut AphroditeState,
content:&str,
classify:Option<(&str, &str)>,
command:Option<&str>,
returncode:Option<i64>,
) -> serde_json::Value {
transform_terminal_output_inner(state, content, classify, command, returncode)
}
fn transform_terminal_output_inner(
state:&mut AphroditeState,
content:&str,
classify:Option<(&str, &str)>,
command:Option<&str>,
returncode:Option<i64>,
) -> serde_json::Value {
if !content.is_empty() {
let ok = returncode.map(|rc| rc == 0).unwrap_or(true);
let args = command.map(|c| serde_json::json!({"command": c}));
let sig = crate::flow::normalize_args_sig("terminal", args.as_ref());
let error_sig = if ok {
None
} else {
let first_err = content
.lines()
.find(|l| l.contains("error") || l.contains("Error") || l.contains("FAILED") || l.contains("panicked"))
.or_else(|| content.lines().next());
Some(crate::flow::error_sig(command, first_err))
};
state.record_tool_event(ToolEvent {
turn:state.turn_counter,
tool:"terminal".to_string(),
sig,
ok,
error_sig,
bytes:content.len(),
wrote_path:None,
});
}
if content.is_empty() {
return serde_json::json!({"status": "ok", "compressed": false, "reason": "empty"});
}
if state.terminal_threshold > 0 && content.len() < state.terminal_threshold {
return serde_json::json!({"status": "ok", "compressed": false, "reason": "below_threshold"});
}
let (type_str, classify_content):(String, &str) = match classify {
Some((c, t)) => (t.to_string(), c),
None => {
let ct = transforms::detect(content);
let t = if content.contains("exit code:") || content.contains("Error:") {
"terminal".to_string()
} else {
let base = ct.as_str().to_string();
match base.as_str() {
"text" | "log" | "plain" | "" => {
crate::preview::detect_semantic_type(content)
.map(|s| s.to_string())
.unwrap_or(base)
},
_ => base,
}
};
(t, content)
},
};
let hash = headroom_core::ccr::compute_key(content.as_bytes());
state.inline_store_put(hash.clone(), content.to_string());
let preview = crate::build_preview(&type_str, classify_content);
let marker = ccr_marker(&hash, &type_str, content.len(), &preview, None, None, None);
state.record_marker(MarkerEntry {
hash:hash.clone(),
ccr_type:type_str.to_string(),
size:content.len(),
preview:preview.clone(),
turn:state.turn_counter,
center:None,
meta:None,
});
serde_json::json!({
"status": "ok",
"compressed": true,
"hash": hash,
"type": type_str,
"size": content.len(),
"preview": preview,
"marker": marker,
})
}
pub fn on_session_start(state:&mut AphroditeState) -> serde_json::Value { crate::session::on_session_start(state) }
pub fn pre_llm_call(state:&mut AphroditeState) -> serde_json::Value {
if state.poll_worker_enabled {
crate::poll_worker::check_bg_tasks(state);
}
let directives = crate::directives::build_directive_context(&state.directives, &state.active_directives);
let context = crate::flow::build_turn_context(state, None);
serde_json::json!({
"status": "ok",
"compressed_count": state.recent_markers.len(),
"directives": if directives.is_empty() { None } else { Some(directives) },
"context": if context.is_empty() { None } else { Some(context) },
})
}
pub fn post_llm_call(state:&mut AphroditeState) -> serde_json::Value {
if let Some(last) = state.recent_markers.iter().rev().find(|m| m.turn == state.turn_counter) {
let (hash, summary, size) = (last.hash.clone(), last.preview.clone(), last.size);
crate::session::archive_turn(state, &hash, &summary, size);
}
crate::session::next_turn(state);
crate::flow::purge_expired_nudges(state);
crate::poll_worker::expire_stale_tasks(state);
serde_json::json!({"status": "ok", "turn": state.turn_counter})
}
fn extract_file_path(content:&str, tool:&str) -> Option<String> {
match tool {
"read_file" | "write_file" | "patch" => {
content.lines().next().and_then(|line| {
let line = line.trim();
if line.starts_with('/') || line.starts_with("./") {
Some(line.to_string())
} else {
None
}
})
},
"search_files" => {
content.lines().next().and_then(|line| {
let path = line.split(':').next().unwrap_or("").trim();
if path.starts_with('/') || path.starts_with("./") {
Some(path.to_string())
} else {
None
}
})
},
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_essential_tool_skip() {
let mut s = AphroditeState::default();
let r = transform_tool_result(&mut s, "some content", "skill_view");
assert_eq!(r["compressed"], false);
assert_eq!(r["reason"], "essential_tool");
}
#[test]
fn test_essential_tool_read_file_still_records_file_reference() {
let mut s = AphroditeState::default();
let r = transform_tool_result(&mut s, "/tmp/some/file.rs\nfn main() {}\n", "read_file");
assert_eq!(r["compressed"], false, "read_file must never be compressed");
assert_eq!(r["reason"], "essential_tool");
assert_eq!(
s.referenced_files.len(),
1,
"read_file must still be tracked as a file reference"
);
assert_eq!(s.referenced_files[0].0, "/tmp/some/file.rs");
}
#[test]
fn test_search_files_records_file_reference() {
let mut s = AphroditeState::default();
s.tool_threshold = 0; let content = "/tmp/some/file.rs:42: let x = 1;\n/tmp/other/file.rs:7: let y = 2;\n";
let _ = transform_tool_result(&mut s, content, "search_files");
assert_eq!(s.referenced_files.len(), 1);
assert_eq!(s.referenced_files[0].0, "/tmp/some/file.rs");
}
#[test]
fn test_empty_skip() {
let mut s = AphroditeState::default();
let r = transform_tool_result(&mut s, "", "terminal");
assert_eq!(r["compressed"], false);
}
#[test]
fn test_below_threshold() {
let mut s = AphroditeState::default();
s.tool_threshold = 10000;
let r = transform_tool_result(&mut s, "short", "terminal");
assert_eq!(r["compressed"], false);
}
#[test]
fn test_transform_success() {
let mut s = AphroditeState::default();
s.tool_threshold = 0; let content = "fn main() {\n println!(\"hello world\");\n}\n";
let r = transform_tool_result(&mut s, content, "terminal");
assert_eq!(r["compressed"], true);
let hash = r["hash"].as_str().unwrap();
assert!(hash.len() >= 40);
let resolved = crate::resolve::expand(&mut s, hash);
assert_eq!(resolved, Some(content.to_string()));
}
#[test]
fn test_terminal_exit_code() {
let mut s = AphroditeState::default();
s.terminal_threshold = 0;
let r = transform_terminal_output(&mut s, "error: broke\nexit code: 1\n");
assert_eq!(r["type"], "terminal");
}
#[test]
fn test_post_llm_call_archives_turn() {
let mut s = AphroditeState::default();
s.turn_counter = 2;
s.record_marker(MarkerEntry {
hash: "hash42".into(),
ccr_type: "text".into(),
size: 100,
preview: "[text] summary".into(),
turn: 2,
center: None,
meta: None,
});
let _ = post_llm_call(&mut s);
assert_eq!(s.turn_counter, 3);
assert_eq!(s.conv_index.len(), 1, "post_llm_call must archive the turn");
assert_eq!(s.conv_index[&2].0, "hash42");
}
#[test]
fn test_pre_llm_call_pushes_poll_nudges() {
let mut s = AphroditeState::default();
s.turn_counter = 3;
crate::poll_worker::insert_bg_task(
&mut s,
"task1".into(),
"terminal".into(),
"cargo build".into(),
1,
);
let _ = pre_llm_call(&mut s);
assert!(
s.ephemeral_directives.len() >= 1,
"pre_llm_call should push poll-worker nudge"
);
let nudge = s.ephemeral_directives.last().unwrap();
assert!(
nudge.inline.as_deref().unwrap().contains("cargo build"),
"nudge should mention the command"
);
}
#[test]
fn test_post_llm_call_expires_stale_poll_tasks() {
let mut s = AphroditeState::default();
s.turn_counter = 20;
crate::poll_worker::insert_bg_task(
&mut s,
"old-task".into(),
"terminal".into(),
"old-build".into(),
10, );
s.bg_tasks[0].last_poll_turn = 10; let _ = post_llm_call(&mut s);
assert_eq!(
s.bg_tasks[0].status,
crate::poll_worker::BgStatus::Stale,
"unpolled task should be expired as stale after {} turns",
crate::poll_worker::STALE_TURN_AGE
);
}
#[test]
fn test_pre_llm_call_skips_nudges_when_poll_worker_disabled() {
let mut s = AphroditeState::default();
s.poll_worker_enabled = false;
s.turn_counter = 3;
crate::poll_worker::insert_bg_task(
&mut s,
"task1".into(),
"terminal".into(),
"cargo build".into(),
1,
);
let before = s.ephemeral_directives.len();
let _ = pre_llm_call(&mut s);
assert_eq!(
s.ephemeral_directives.len(),
before,
"disabled flag must prevent poll-worker nudges in pre_llm_call"
);
}
#[test]
fn test_post_llm_call_still_expires_when_poll_worker_disabled() {
let mut s = AphroditeState::default();
s.poll_worker_enabled = false;
s.turn_counter = 20;
crate::poll_worker::insert_bg_task(
&mut s,
"old-task".into(),
"terminal".into(),
"old-build".into(),
10,
);
s.bg_tasks[0].last_poll_turn = 10;
let _ = post_llm_call(&mut s);
assert_eq!(
s.bg_tasks[0].status,
crate::poll_worker::BgStatus::Stale,
"expire_stale_tasks must still run when disabled (cleanup)"
);
}
}