use std::io::{self, Write};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use crate::{
compact_session, estimate_session_tokens, CompactionConfig, ConversationRuntime,
PermissionMode, PermissionPolicy, PermissionPromptDecision, PermissionPrompter,
PermissionRequest, Session, TurnSummary,
};
use anyhow::{Context, Result};
use crate::api::{stdout_text_callback, telegram_text_callback, OllamaApiClient};
use crate::commands::{dispatch_slash_command, parse_slash_command, ReplState, SlashOutcome};
use crate::executor::SecretaryToolExecutor;
use crate::memory::try_load_memory;
use crate::model_config;
use crate::prompt::secretary_system_prompt_with_memory;
use crate::theme;
use crate::tool_groups::{ToolGroup, ToolRegistry};
pub const DEFAULT_COMPACT_THRESHOLD: usize = 1_000_000;
#[must_use]
pub fn compact_threshold() -> usize {
std::env::var("CLAUDETTE_COMPACT_THRESHOLD")
.ok()
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(DEFAULT_COMPACT_THRESHOLD)
}
#[must_use]
pub fn soft_compact_threshold() -> Option<usize> {
std::env::var("CLAUDETTE_SOFT_COMPACT_THRESHOLD")
.ok()
.and_then(|s| s.parse::<usize>().ok())
.filter(|n| *n > 0)
}
const HARD_COMPACT_PRESERVE: usize = 4;
const SOFT_COMPACT_PRESERVE: usize = 12;
pub const DEFAULT_MAX_ITERATIONS: usize = 40;
#[must_use]
pub fn max_iterations() -> usize {
std::env::var("CLAUDETTE_MAX_ITERATIONS")
.ok()
.and_then(|s| s.parse::<usize>().ok())
.filter(|n| *n > 0)
.unwrap_or(DEFAULT_MAX_ITERATIONS)
}
#[must_use]
pub fn current_model() -> String {
model_config::active().brain.model
}
#[derive(Debug, Clone, Default)]
pub struct SessionOptions {
pub resume: bool,
pub autosave: bool,
}
#[must_use]
pub fn default_session_path() -> PathBuf {
if let Ok(custom) = std::env::var("CLAUDETTE_SESSION") {
if !custom.is_empty() {
return PathBuf::from(custom);
}
}
sessions_dir().join("last.json")
}
pub(crate) fn sessions_dir() -> PathBuf {
let home = std::env::var("USERPROFILE")
.or_else(|_| std::env::var("HOME"))
.unwrap_or_else(|_| ".".to_string());
PathBuf::from(home).join(".claudette").join("sessions")
}
pub fn try_load_session() -> Result<Option<Session>> {
try_load_session_at(&default_session_path())
}
pub fn try_load_session_at(path: &std::path::Path) -> Result<Option<Session>> {
if !path.exists() {
return Ok(None);
}
let session = Session::load_from_path(path)
.with_context(|| format!("failed to load session from {}", path.display()))?;
Ok(Some(session))
}
pub fn save_session(session: &Session) -> Result<()> {
save_session_at(session, &default_session_path())
}
pub fn save_session_at(session: &Session, path: &std::path::Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
session
.save_to_path(path)
.with_context(|| format!("failed to save session to {}", path.display()))?;
Ok(())
}
pub fn run_secretary(user_input: &str, opts: SessionOptions) -> Result<TurnSummary> {
let session = if opts.resume {
try_load_session()?.ok_or_else(|| {
anyhow::anyhow!("no saved session at {}", default_session_path().display())
})?
} else {
Session::default()
};
let mut runtime = build_runtime(session);
crate::tools::set_current_turn_paths(crate::tools::extract_user_prompt_paths(user_input));
let mut no_prompter: Option<&mut dyn PermissionPrompter> = None;
let summary =
crate::brain_selector::run_turn_with_fallback(&mut runtime, user_input, &mut no_prompter)
.map_err(|e| anyhow::anyhow!("secretary turn failed: {e}"))?;
if let Some(removed) = maybe_compact_session(&mut runtime, false) {
eprintln!("[auto-compacted {removed} older message(s)]");
}
if opts.autosave {
save_session(runtime.session())?;
}
Ok(summary)
}
pub fn run_secretary_repl(opts: SessionOptions) -> Result<()> {
theme::init();
let session = if opts.resume {
match try_load_session()? {
Some(s) => {
eprintln!(
"{} {} {}",
theme::SAVE,
theme::ok("resumed session"),
theme::dim(&format!(
"from {} ({} messages)",
default_session_path().display(),
s.messages.len()
))
);
s
}
None => {
eprintln!(
"{} {}",
theme::dim("○"),
theme::dim(&format!(
"no saved session at {} — starting fresh",
default_session_path().display()
))
);
Session::default()
}
}
} else {
Session::default()
};
let mut runtime = build_runtime_streaming(session, false);
let mut state = ReplState::default();
let mut prompter = CliPrompter;
eprintln!(
"{} {} {}",
theme::ROBOT,
theme::brand("claudette"),
theme::dim("— your local secretary")
);
eprintln!(
"{} {}",
theme::SPARKLES,
theme::dim("type /help for commands, /exit (or Ctrl-D) to leave")
);
eprintln!(
"{} {}",
theme::SAVE,
theme::dim(&format!("session: {}", default_session_path().display()))
);
eprintln!();
loop {
{
let stderr = io::stderr();
let mut err = stderr.lock();
write!(err, "{} ", theme::accent(theme::PROMPT_ARROW))?;
err.flush()?;
}
let line = {
let stdin = io::stdin();
let mut buf = String::new();
match stdin.read_line(&mut buf) {
Ok(0) => {
eprintln!();
break; }
Ok(_) => buf,
Err(e) => {
eprintln!("stdin error: {e}");
break;
}
}
};
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if matches!(trimmed, "exit" | "quit" | ":q") {
break;
}
if let Some(cmd) = parse_slash_command(trimmed) {
match dispatch_slash_command(cmd, &mut runtime, &state) {
SlashOutcome::Continue => continue,
SlashOutcome::Exit => break,
}
}
crate::tools::set_current_turn_paths(crate::tools::extract_user_prompt_paths(trimmed));
let extracted = crate::image_attach::extract_image_attachments_from_input(trimmed);
if extracted.extension_matches > 0 && extracted.attached.is_empty() {
if let Some(reason) = &extracted.first_failure {
eprintln!(
"{} {}",
theme::WARN_GLYPH,
theme::warn(&format!(
"image-path detected but couldn't attach: {reason}"
))
);
}
}
let turn_result: Result<TurnSummary, String> = if extracted.attached.is_empty() {
let mut prompter_opt: Option<&mut dyn PermissionPrompter> = Some(&mut prompter);
crate::brain_selector::run_turn_with_fallback(&mut runtime, trimmed, &mut prompter_opt)
} else {
let count = extracted.attached.len();
eprintln!(
"{} {}",
theme::SAVE,
theme::dim(&format!("📎 attached {count} image(s) — routing to vision"))
);
let images: Vec<(String, String)> = extracted
.attached
.into_iter()
.map(|a| (a.media_type, a.data_b64))
.collect();
runtime
.run_turn_with_images(trimmed, images, Some(&mut prompter))
.map_err(|e| e.to_string())
};
match turn_result {
Ok(summary) => {
state.record_turn(summary.usage.input_tokens, summary.usage.output_tokens);
eprintln!(
"{} {}",
theme::BOLT,
theme::info(&format!(
"turn iter={} in={} out={}",
summary.iterations, summary.usage.input_tokens, summary.usage.output_tokens,
))
);
if !recall_disabled() {
if let Err(e) = index_turn_for_recall(trimmed, &runtime) {
eprintln!(
"{} {}",
theme::warn(theme::WARN_GLYPH),
theme::warn(&format!("recall: {e}"))
);
}
}
if let Some(removed) = maybe_compact_session(&mut runtime, false) {
eprintln!(
"{} {}",
theme::SAVE,
theme::ok(&format!(
"auto-compacted {removed} older message(s) — session was over {}-token threshold",
compact_threshold(),
))
);
}
if opts.autosave {
if let Err(e) = save_session(runtime.session()) {
eprintln!(
"{} {}",
theme::warn(theme::WARN_GLYPH),
theme::warn(&format!("session save failed: {e:#}"))
);
}
}
}
Err(e) => {
eprintln!(
"{} {}",
theme::error(theme::ERR_GLYPH),
theme::error(&format!("turn failed: {e}"))
);
}
}
}
Ok(())
}
pub(crate) fn build_runtime(
session: Session,
) -> ConversationRuntime<OllamaApiClient, SecretaryToolExecutor> {
build_runtime_inner(session, false, false)
}
pub(crate) fn build_runtime_streaming(
session: Session,
telegram: bool,
) -> ConversationRuntime<OllamaApiClient, SecretaryToolExecutor> {
build_runtime_inner(session, true, telegram)
}
fn build_runtime_inner(
session: Session,
streaming: bool,
telegram: bool,
) -> ConversationRuntime<OllamaApiClient, SecretaryToolExecutor> {
let brain = model_config::active().brain;
build_runtime_with_brain(session, &brain, streaming, telegram)
}
pub(crate) fn build_runtime_with_brain(
session: Session,
brain: &crate::model_config::RoleConfig,
streaming: bool,
telegram: bool,
) -> ConversationRuntime<OllamaApiClient, SecretaryToolExecutor> {
let reg = ToolRegistry::new();
let registry = Arc::new(Mutex::new(reg));
let mut api_client = OllamaApiClient::with_registry(brain.model.clone(), registry.clone())
.with_context(brain.num_ctx)
.with_max_predict(brain.num_predict);
if streaming {
let cb = if telegram {
telegram_text_callback()
} else {
stdout_text_callback()
};
api_client = api_client.with_text_callback(cb);
}
let hinter_registry = Arc::clone(®istry);
let executor = SecretaryToolExecutor::with_registry(registry);
let policy = build_permission_policy();
let memory = try_load_memory();
ConversationRuntime::new(
session,
api_client,
executor,
policy,
secretary_system_prompt_with_memory(memory.as_deref(), telegram),
)
.with_max_iterations(max_iterations())
.with_auto_compaction_input_tokens_threshold(u32::MAX)
.with_unknown_tool_hinter(move |name: &str| {
ToolGroup::parse(name).map_or_else(Vec::new, |group| {
let reg = match hinter_registry.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
reg.group_tool_names(group)
})
})
}
pub(crate) fn build_permission_policy() -> PermissionPolicy {
use PermissionMode::{DangerFullAccess, ReadOnly, WorkspaceWrite};
PermissionPolicy::new(WorkspaceWrite)
.with_tool_requirement("get_current_time", ReadOnly)
.with_tool_requirement("note_list", ReadOnly)
.with_tool_requirement("note_read", ReadOnly)
.with_tool_requirement("todo_list", ReadOnly)
.with_tool_requirement("enable_tools", ReadOnly)
.with_tool_requirement("read_file", ReadOnly)
.with_tool_requirement("list_dir", ReadOnly)
.with_tool_requirement("get_capabilities", ReadOnly)
.with_tool_requirement("load_workspace_rules", ReadOnly)
.with_tool_requirement("glob_search", ReadOnly)
.with_tool_requirement("grep_search", ReadOnly)
.with_tool_requirement("git_status", ReadOnly)
.with_tool_requirement("git_diff", ReadOnly)
.with_tool_requirement("git_log", ReadOnly)
.with_tool_requirement("git_branch", ReadOnly)
.with_tool_requirement("note_create", WorkspaceWrite)
.with_tool_requirement("note_update", WorkspaceWrite)
.with_tool_requirement("note_delete", WorkspaceWrite)
.with_tool_requirement("todo_add", WorkspaceWrite)
.with_tool_requirement("todo_complete", WorkspaceWrite)
.with_tool_requirement("todo_uncomplete", WorkspaceWrite)
.with_tool_requirement("todo_delete", WorkspaceWrite)
.with_tool_requirement("write_file", WorkspaceWrite)
.with_tool_requirement("generate_code", WorkspaceWrite)
.with_tool_requirement("web_search", WorkspaceWrite)
.with_tool_requirement("web_fetch", WorkspaceWrite)
.with_tool_requirement("open_in_editor", WorkspaceWrite)
.with_tool_requirement("reveal_in_explorer", WorkspaceWrite)
.with_tool_requirement("open_url", WorkspaceWrite)
.with_tool_requirement("add_numbers", WorkspaceWrite)
.with_tool_requirement("spawn_agent", WorkspaceWrite)
.with_tool_requirement("wikipedia_search", ReadOnly)
.with_tool_requirement("wikipedia_summary", ReadOnly)
.with_tool_requirement("weather_current", ReadOnly)
.with_tool_requirement("weather_forecast", ReadOnly)
.with_tool_requirement("crate_info", ReadOnly)
.with_tool_requirement("crate_search", ReadOnly)
.with_tool_requirement("npm_info", ReadOnly)
.with_tool_requirement("npm_search", ReadOnly)
.with_tool_requirement("gh_list_my_prs", ReadOnly)
.with_tool_requirement("gh_list_assigned_issues", ReadOnly)
.with_tool_requirement("gh_get_issue", ReadOnly)
.with_tool_requirement("gh_search_code", ReadOnly)
.with_tool_requirement("gh_create_issue", WorkspaceWrite)
.with_tool_requirement("gh_comment_issue", WorkspaceWrite)
.with_tool_requirement("tv_get_quote", ReadOnly)
.with_tool_requirement("tv_technical_rating", ReadOnly)
.with_tool_requirement("tv_search_symbol", ReadOnly)
.with_tool_requirement("tv_economic_calendar", ReadOnly)
.with_tool_requirement("vestige_asa_info", ReadOnly)
.with_tool_requirement("vestige_search_asa", ReadOnly)
.with_tool_requirement("vestige_top_movers", ReadOnly)
.with_tool_requirement("tg_get_updates", ReadOnly)
.with_tool_requirement("tg_send", WorkspaceWrite)
.with_tool_requirement("tg_send_photo", WorkspaceWrite)
.with_tool_requirement("calendar_list_events", ReadOnly)
.with_tool_requirement("calendar_create_event", WorkspaceWrite)
.with_tool_requirement("calendar_update_event", WorkspaceWrite)
.with_tool_requirement("calendar_respond_to_event", WorkspaceWrite)
.with_tool_requirement("calendar_delete_event", DangerFullAccess)
.with_tool_requirement("gmail_list", ReadOnly)
.with_tool_requirement("gmail_search", ReadOnly)
.with_tool_requirement("gmail_read", ReadOnly)
.with_tool_requirement("gmail_list_labels", ReadOnly)
.with_tool_requirement("schedule_list", ReadOnly)
.with_tool_requirement("schedule_once", WorkspaceWrite)
.with_tool_requirement("schedule_recurring", WorkspaceWrite)
.with_tool_requirement("schedule_cancel", WorkspaceWrite)
.with_tool_requirement("recall", ReadOnly)
.with_tool_requirement("bash", DangerFullAccess)
.with_tool_requirement("edit_file", DangerFullAccess)
.with_tool_requirement("git_add", DangerFullAccess)
.with_tool_requirement("git_commit", DangerFullAccess)
.with_tool_requirement("git_push", DangerFullAccess)
.with_tool_requirement("git_checkout", DangerFullAccess)
}
fn recall_disabled() -> bool {
matches!(
std::env::var("CLAUDETTE_RECALL_DISABLE").as_deref(),
Ok("1")
)
}
fn index_turn_for_recall(
user_input: &str,
runtime: &ConversationRuntime<OllamaApiClient, SecretaryToolExecutor>,
) -> Result<(), String> {
use crate::recall::{global_index, Role};
use crate::ContentBlock;
let user_text = user_input.trim();
if !user_text.is_empty() {
global_index(Role::User, user_text)?;
}
if let Some(msg) = runtime
.session()
.messages
.iter()
.rev()
.find(|m| matches!(m.role, crate::MessageRole::Assistant))
{
let mut text = String::new();
for block in &msg.blocks {
if let ContentBlock::Text { text: t } = block {
if !text.is_empty() {
text.push('\n');
}
text.push_str(t);
}
}
if !text.trim().is_empty() {
global_index(Role::Assistant, &text)?;
}
}
Ok(())
}
pub struct CliPrompter;
impl PermissionPrompter for CliPrompter {
fn decide(&mut self, request: &PermissionRequest) -> PermissionPromptDecision {
let stderr = io::stderr();
let mut err = stderr.lock();
let _ = writeln!(err);
let input_chars = request.input.chars().count();
let _ = writeln!(
err,
" {} {} wants to run ({} chars):",
theme::warn(theme::WARN_GLYPH),
theme::accent(&request.tool_name),
input_chars
);
if request.input.is_empty() {
let _ = writeln!(err, " {}", theme::dim("(empty input)"));
} else {
for line in request.input.lines() {
let _ = writeln!(err, " {}", theme::dim(line));
}
}
let _ = write!(err, " Allow? [y/N] ");
let _ = err.flush();
let stdin = io::stdin();
let mut buf = String::new();
match stdin.read_line(&mut buf) {
Ok(_) => {
let answer = buf.trim().to_lowercase();
if answer == "y" || answer == "yes" {
PermissionPromptDecision::Allow
} else {
PermissionPromptDecision::Deny {
reason: "user denied permission".to_string(),
}
}
}
Err(_) => PermissionPromptDecision::Deny {
reason: "could not read user input".to_string(),
},
}
}
}
const EMPTY_RESPONSE_NUDGE: &str =
"Your response was empty. If you need a tool that isn't available, \
call enable_tools(group) to load it first, then call the tool. \
Otherwise, answer the question directly with text.";
pub(crate) fn run_turn_with_retry(
runtime: &mut ConversationRuntime<OllamaApiClient, SecretaryToolExecutor>,
input: &str,
prompter: Option<&mut dyn PermissionPrompter>,
) -> Result<TurnSummary, String> {
crate::tools::set_current_turn_paths(crate::tools::extract_user_prompt_paths(input));
match runtime.run_turn(input, prompter) {
Ok(summary) => return Ok(summary),
Err(e) => {
let msg = e.to_string();
if !msg.contains("no content") {
return Err(msg);
}
eprintln!(
" {} {}",
theme::dim("▸"),
theme::dim("empty response — retrying with enable_tools hint...")
);
}
}
runtime
.run_turn(EMPTY_RESPONSE_NUDGE, None)
.map_err(|e| e.to_string())
}
pub(crate) fn maybe_compact_session(
runtime: &mut ConversationRuntime<OllamaApiClient, SecretaryToolExecutor>,
telegram: bool,
) -> Option<usize> {
let estimated = estimate_session_tokens(runtime.session());
let preserve = pick_compact_preserve(estimated, compact_threshold(), soft_compact_threshold())?;
let result = compact_session(
runtime.session(),
CompactionConfig {
preserve_recent_messages: preserve,
max_estimated_tokens: 0,
},
);
if result.removed_message_count == 0 {
return None;
}
let removed = result.removed_message_count;
*runtime = build_runtime_streaming(result.compacted_session, telegram);
Some(removed)
}
#[must_use]
fn pick_compact_preserve(
estimated: usize,
hard_threshold: usize,
soft_threshold: Option<usize>,
) -> Option<usize> {
if estimated >= hard_threshold {
return Some(HARD_COMPACT_PRESERVE);
}
if let Some(soft) = soft_threshold {
if estimated >= soft {
return Some(SOFT_COMPACT_PRESERVE);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{ContentBlock, ConversationMessage, MessageRole};
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
fn temp_session_file(label: &str) -> PathBuf {
let dir = std::env::temp_dir().join("claudette-test-sessions");
let _ = std::fs::create_dir_all(&dir);
dir.join(format!(
"{label}-{}-{}.json",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos())
))
}
#[test]
fn default_session_path_honors_env_var() {
let _guard = ENV_LOCK.lock().unwrap();
let path = temp_session_file("env-var");
let prev = std::env::var("CLAUDETTE_SESSION").ok();
std::env::set_var("CLAUDETTE_SESSION", &path);
let resolved = default_session_path();
assert_eq!(resolved, path);
match prev {
Some(v) => std::env::set_var("CLAUDETTE_SESSION", v),
None => std::env::remove_var("CLAUDETTE_SESSION"),
}
}
#[test]
fn save_then_load_round_trip() {
let path = temp_session_file("round-trip");
let mut session = Session::default();
session.messages.push(ConversationMessage {
role: MessageRole::User,
blocks: vec![ContentBlock::Text {
text: "remember this".to_string(),
}],
usage: None,
});
save_session_at(&session, &path).expect("save should succeed");
let loaded = try_load_session_at(&path)
.expect("load should not error")
.expect("session should be present");
assert_eq!(loaded.messages.len(), 1);
if let ContentBlock::Text { text } = &loaded.messages[0].blocks[0] {
assert_eq!(text, "remember this");
} else {
panic!("expected text block");
}
let _ = std::fs::remove_file(&path);
}
#[test]
fn try_load_returns_none_when_missing() {
let path = temp_session_file("missing");
let _ = std::fs::remove_file(&path); let result = try_load_session_at(&path).expect("missing file should not error");
assert!(result.is_none());
}
#[test]
fn compact_threshold_default_when_env_var_unset() {
let _guard = ENV_LOCK.lock().unwrap();
let prev = std::env::var("CLAUDETTE_COMPACT_THRESHOLD").ok();
std::env::remove_var("CLAUDETTE_COMPACT_THRESHOLD");
assert_eq!(compact_threshold(), DEFAULT_COMPACT_THRESHOLD);
if let Some(v) = prev {
std::env::set_var("CLAUDETTE_COMPACT_THRESHOLD", v);
}
}
#[test]
fn compact_threshold_honors_env_var() {
let _guard = ENV_LOCK.lock().unwrap();
let prev = std::env::var("CLAUDETTE_COMPACT_THRESHOLD").ok();
std::env::set_var("CLAUDETTE_COMPACT_THRESHOLD", "12345");
assert_eq!(compact_threshold(), 12345);
match prev {
Some(v) => std::env::set_var("CLAUDETTE_COMPACT_THRESHOLD", v),
None => std::env::remove_var("CLAUDETTE_COMPACT_THRESHOLD"),
}
}
#[test]
fn compact_threshold_falls_back_on_garbage() {
let _guard = ENV_LOCK.lock().unwrap();
let prev = std::env::var("CLAUDETTE_COMPACT_THRESHOLD").ok();
std::env::set_var("CLAUDETTE_COMPACT_THRESHOLD", "not-a-number");
assert_eq!(compact_threshold(), DEFAULT_COMPACT_THRESHOLD);
match prev {
Some(v) => std::env::set_var("CLAUDETTE_COMPACT_THRESHOLD", v),
None => std::env::remove_var("CLAUDETTE_COMPACT_THRESHOLD"),
}
}
#[test]
fn maybe_compact_session_no_op_when_under_threshold() {
let _guard = ENV_LOCK.lock().unwrap();
let prev = std::env::var("CLAUDETTE_COMPACT_THRESHOLD").ok();
std::env::set_var("CLAUDETTE_COMPACT_THRESHOLD", "1000000");
let mut session = Session::default();
session.messages.push(ConversationMessage {
role: MessageRole::User,
blocks: vec![ContentBlock::Text {
text: "tiny".to_string(),
}],
usage: None,
});
let messages_before = session.messages.len();
let mut runtime = build_runtime(session);
let result = maybe_compact_session(&mut runtime, false);
assert!(
result.is_none(),
"should not compact when session is under threshold"
);
assert_eq!(runtime.session().messages.len(), messages_before);
match prev {
Some(v) => std::env::set_var("CLAUDETTE_COMPACT_THRESHOLD", v),
None => std::env::remove_var("CLAUDETTE_COMPACT_THRESHOLD"),
}
}
#[test]
fn maybe_compact_session_fires_when_over_threshold() {
let _guard = ENV_LOCK.lock().unwrap();
let prev = std::env::var("CLAUDETTE_COMPACT_THRESHOLD").ok();
std::env::set_var("CLAUDETTE_COMPACT_THRESHOLD", "10");
let mut session = Session::default();
for i in 0..8 {
session.messages.push(ConversationMessage {
role: MessageRole::User,
blocks: vec![ContentBlock::Text {
text: format!("turn {i} content padded long enough to register"),
}],
usage: None,
});
}
let mut runtime = build_runtime(session);
let messages_before = runtime.session().messages.len();
let result = maybe_compact_session(&mut runtime, false);
let removed = result.expect("expected compaction to fire");
assert!(removed > 0, "should remove at least one message");
assert!(runtime.session().messages.len() < messages_before);
match prev {
Some(v) => std::env::set_var("CLAUDETTE_COMPACT_THRESHOLD", v),
None => std::env::remove_var("CLAUDETTE_COMPACT_THRESHOLD"),
}
}
#[test]
fn save_creates_parent_directory() {
let path = temp_session_file("nested")
.parent()
.unwrap()
.join("nested-subdir")
.join("session.json");
let _ = std::fs::remove_dir_all(path.parent().unwrap());
save_session_at(&Session::default(), &path).expect("save should create parents");
assert!(path.exists());
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}
#[test]
fn pick_compact_preserve_returns_none_below_both_thresholds() {
assert_eq!(
pick_compact_preserve(50_000, 1_000_000, Some(200_000)),
None
);
assert_eq!(pick_compact_preserve(50_000, 1_000_000, None), None);
}
#[test]
fn pick_compact_preserve_returns_soft_when_only_soft_crossed() {
assert_eq!(
pick_compact_preserve(250_000, 1_000_000, Some(200_000)),
Some(SOFT_COMPACT_PRESERVE)
);
}
#[test]
fn pick_compact_preserve_returns_hard_when_hard_crossed() {
assert_eq!(
pick_compact_preserve(1_500_000, 1_000_000, Some(200_000)),
Some(HARD_COMPACT_PRESERVE)
);
}
#[test]
fn pick_compact_preserve_prefers_hard_over_soft_when_both_crossed() {
assert_eq!(
pick_compact_preserve(2_000_000, 1_000_000, Some(200_000)),
Some(HARD_COMPACT_PRESERVE)
);
}
#[test]
fn pick_compact_preserve_skips_soft_when_threshold_unset() {
assert_eq!(pick_compact_preserve(500_000, 1_000_000, None), None);
}
#[test]
fn soft_compact_threshold_returns_none_when_unset() {
let _guard = ENV_LOCK.lock().unwrap();
let prev = std::env::var("CLAUDETTE_SOFT_COMPACT_THRESHOLD").ok();
std::env::remove_var("CLAUDETTE_SOFT_COMPACT_THRESHOLD");
assert_eq!(soft_compact_threshold(), None);
if let Some(v) = prev {
std::env::set_var("CLAUDETTE_SOFT_COMPACT_THRESHOLD", v);
}
}
#[test]
fn soft_compact_threshold_returns_some_when_set() {
let _guard = ENV_LOCK.lock().unwrap();
let prev = std::env::var("CLAUDETTE_SOFT_COMPACT_THRESHOLD").ok();
std::env::set_var("CLAUDETTE_SOFT_COMPACT_THRESHOLD", "200000");
assert_eq!(soft_compact_threshold(), Some(200_000));
match prev {
Some(v) => std::env::set_var("CLAUDETTE_SOFT_COMPACT_THRESHOLD", v),
None => std::env::remove_var("CLAUDETTE_SOFT_COMPACT_THRESHOLD"),
}
}
#[test]
fn soft_compact_threshold_treats_zero_as_unset() {
let _guard = ENV_LOCK.lock().unwrap();
let prev = std::env::var("CLAUDETTE_SOFT_COMPACT_THRESHOLD").ok();
std::env::set_var("CLAUDETTE_SOFT_COMPACT_THRESHOLD", "0");
assert_eq!(soft_compact_threshold(), None);
match prev {
Some(v) => std::env::set_var("CLAUDETTE_SOFT_COMPACT_THRESHOLD", v),
None => std::env::remove_var("CLAUDETTE_SOFT_COMPACT_THRESHOLD"),
}
}
#[test]
fn every_advertised_tool_has_permission_requirement() {
let policy = build_permission_policy();
let full = crate::tools::secretary_tools_json();
let arr = full.as_array().cloned().unwrap_or_default();
let mut missing: Vec<String> = Vec::new();
for tool in arr {
let Some(name) = tool
.pointer("/function/name")
.and_then(serde_json::Value::as_str)
.map(str::to_string)
else {
continue;
};
if !policy.is_known(&name) {
missing.push(name);
}
}
assert!(
missing.is_empty(),
"tool(s) advertised but not registered in build_permission_policy() — \
will be swallowed by the unknown-tool short-circuit and never reach \
the dispatcher: {missing:?}. Add a `.with_tool_requirement(name, ...)` \
entry."
);
}
}