use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode};
use std::time::{Duration, Instant};
use nojson::{DisplayJson, RawJson};
use crate::curl::{self, ProgressSinks};
use crate::permissions;
use crate::sansio::agent::{
CommandError, CommandInvocation, PatchError, PatchInvocation, PatchPreview, PatchTool,
ReadOnlyTool, ToolExecutionError, ToolOutcome,
};
use crate::sansio::deepseek::{ChatMessage, ChatRequest, ToolCall, ToolDef};
use crate::sansio::permissions::{
Authorization, AutoDecision, Judgment, Rule, RuleScope, evaluate, evaluate_write,
};
use crate::session::{
ApprovalDecision, AutoDecidedBy, AutoDecidedMatch, ChatMessageWithTs, InvocationEndReason,
MetricsSnapshotBody, Pending, PendingToolKind, Session, SessionRecord, TokenUsageBody,
now_unix_millis,
};
use crate::tools::ToolExecutor;
pub const EXIT_OK: u8 = 0;
pub const EXIT_ERROR: u8 = 1;
pub const EXIT_AWAITING_APPROVAL: u8 = 10;
pub const DEFAULT_MAX_TURNS: usize = 20;
#[derive(Debug, Default)]
pub struct Counters {
pub turns: u64,
pub tool_calls_by_kind: ToolCallsByKind,
pub tool_errors: u64,
pub prompt_tokens_billed_total: u64,
pub prompt_tokens_last: u64,
pub completion_tokens_total: u64,
pub prompt_cache_hit_tokens_total: u64,
pub prompt_cache_miss_tokens_total: u64,
pub compaction_attempts: u64,
pub compaction_failures: u64,
}
#[derive(Debug, Default)]
pub struct ToolCallsByKind {
pub list: u64,
pub read: u64,
pub search: u64,
pub patch: u64,
pub command: u64,
pub unknown: u64,
}
impl Counters {
pub fn to_metrics_entries(&self, duration_ms: u64) -> Vec<(String, u64)> {
vec![
("turns".to_string(), self.turns),
("tool_calls.list".to_string(), self.tool_calls_by_kind.list),
("tool_calls.read".to_string(), self.tool_calls_by_kind.read),
(
"tool_calls.search".to_string(),
self.tool_calls_by_kind.search,
),
(
"tool_calls.patch".to_string(),
self.tool_calls_by_kind.patch,
),
(
"tool_calls.command".to_string(),
self.tool_calls_by_kind.command,
),
(
"tool_calls.unknown".to_string(),
self.tool_calls_by_kind.unknown,
),
("tool_errors".to_string(), self.tool_errors),
("duration_ms".to_string(), duration_ms),
(
"prompt_tokens_billed_total".to_string(),
self.prompt_tokens_billed_total,
),
(
"completion_tokens_total".to_string(),
self.completion_tokens_total,
),
(
"prompt_cache_hit_tokens_total".to_string(),
self.prompt_cache_hit_tokens_total,
),
(
"prompt_cache_miss_tokens_total".to_string(),
self.prompt_cache_miss_tokens_total,
),
("compaction_attempts".to_string(), self.compaction_attempts),
("compaction_failures".to_string(), self.compaction_failures),
]
}
}
pub const COMPACTION_TRIGGER_TOKENS: u64 = 16_384;
pub const PATCH_PREVIEW_MAX_LINES: usize = 200;
pub const READ_PREVIEW_MAX_LINES: usize = 20;
pub const KEEP_RECENT_RECORDS_TARGET: usize = 10;
pub const SUMMARY_MAX_CHARS: usize = 200_000;
pub const SUMMARY_RECORD_MAX_CHARS: usize = 16_000;
pub const SUMMARY_TOOL_RESULT_MAX_CHARS: usize = 200;
pub const RETAINED_TAIL_MAX_CHARS: usize = 250_000;
pub const RECORDS_TOTAL_MAX_CHARS: usize = 250_000;
pub const CONVERSATION_PRUNE_TRIGGER_BYTES: u64 = 100 * 1024 * 1024;
pub struct TellConfig {
pub session_name: String,
pub model: String,
pub max_tokens: Option<u64>,
pub workspace_root: PathBuf,
pub system_prompt: Option<String>,
pub max_turns: usize,
pub turn_tool_call_limit: usize,
pub tool_call_rate: Option<RateLimit>,
pub session_tool_call_max: Option<usize>,
pub authorization: Authorization,
pub temperature: Option<f64>,
pub grant_request: GrantRequest,
pub command_timeout_seconds: Option<u64>,
}
pub const DEFAULT_COMMAND_TIMEOUT_SECONDS: u64 = 180;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GrantRequest {
None,
Oneshot,
Session,
Workspace,
}
pub const DEFAULT_TURN_TOOL_CALL_LIMIT: usize = 20;
pub const DEFAULT_TOOL_CALL_RATE_CALLS: usize = 60;
pub const DEFAULT_TOOL_CALL_RATE_WINDOW_SECS: u64 = 60;
pub const DEFAULT_SESSION_TOOL_CALL_MAX: usize = 5000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RateLimit {
pub calls: usize,
pub window: Duration,
}
pub enum Continuation {
Prompt(String),
Approve,
Retry,
}
pub const RESUME_PROMPT: &str = "Continue from where you left off.";
#[derive(Debug)]
pub enum TellOutcome {
Exit(ExitCode),
}
pub fn run(cfg: TellConfig, cont: Continuation) -> io::Result<TellOutcome> {
let mut session = Session::open(&cfg.session_name)?;
let loaded = permissions::load(&cfg.session_name)?;
let candidates: Vec<PathBuf> = loaded.extra_read_paths.iter().map(PathBuf::from).collect();
let extra_read_roots = canonicalise_extra_read_roots(&cfg.workspace_root, candidates);
let mut executor = ToolExecutor::new(
&cfg.workspace_root,
extra_read_roots,
cfg.session_name.clone(),
)?;
let mut write_rules: Vec<Rule> = Vec::new();
write_rules.extend(loaded.workspace.iter().cloned());
write_rules.extend(loaded.session.iter().cloned());
executor.set_write_rules(write_rules);
let start_ts = now_unix_millis();
session.append(&SessionRecord::InvocationStart {
ts: start_ts,
attini_version: env!("CARGO_PKG_VERSION").to_string(),
model: cfg.model.clone(),
})?;
if std::env::var("ATTINI_STATUS_LINE").as_deref() != Ok("0") {
let ctx_tokens = session.latest_prompt_tokens().ok().flatten().unwrap_or(0);
eprintln!(
"{}",
render_tell_status_line(&cfg.model, &cfg.session_name, ctx_tokens)
);
}
let mut counters = Counters::default();
let outcome = drive(&mut session, &executor, &cfg, cont, &mut counters);
let (reason, exit_code) = match &outcome {
Ok(Driven::Completed) => (InvocationEndReason::Completed, EXIT_OK),
Ok(Driven::AwaitingApproval) => (
InvocationEndReason::AwaitingApproval,
EXIT_AWAITING_APPROVAL,
),
Ok(Driven::SessionToolCallExhausted) => {
(InvocationEndReason::SessionToolCallExhausted, EXIT_ERROR)
}
Ok(Driven::TransportFailed(_)) => (InvocationEndReason::TransportError, EXIT_ERROR),
Err(_) => (InvocationEndReason::Error, EXIT_ERROR),
};
let end_ts = now_unix_millis();
let duration_ms = end_ts.saturating_sub(start_ts);
let _ = session.append(&SessionRecord::MetricsSnapshot {
ts: end_ts,
counters: MetricsSnapshotBody {
entries: counters.to_metrics_entries(duration_ms),
},
});
let _ = session.append(&SessionRecord::InvocationEnd { ts: end_ts, reason });
match outcome {
Ok(Driven::TransportFailed(message)) => {
eprintln!("attini: {message}");
eprintln!(
"attini: this looks like a transient transport failure; run \
`attini approve -s {}` to re-issue the same request",
cfg.session_name
);
Ok(TellOutcome::Exit(ExitCode::from(EXIT_ERROR)))
}
Ok(_) => Ok(TellOutcome::Exit(ExitCode::from(exit_code))),
Err(e) => {
eprintln!("attini: {e}");
Ok(TellOutcome::Exit(ExitCode::from(EXIT_ERROR)))
}
}
}
fn render_tell_status_line(model: &str, session_name: &str, ctx_tokens: u64) -> String {
format!(
"[tell] model={} session={} ctx={}",
model, session_name, ctx_tokens,
)
}
enum Driven {
Completed,
AwaitingApproval,
SessionToolCallExhausted,
TransportFailed(String),
}
struct ToolCallGate {
turn_limit: usize,
rate: Option<RateLimit>,
session_max: Option<usize>,
turn_count: usize,
rate_deque: VecDeque<Instant>,
session_count: usize,
}
#[derive(Debug, PartialEq, Eq)]
enum GateDecision {
Proceed,
TurnLimitExceeded,
RateLimitExceeded,
SessionExhausted,
}
impl ToolCallGate {
fn new(cfg: &TellConfig) -> Self {
Self {
turn_limit: cfg.turn_tool_call_limit,
rate: cfg.tool_call_rate,
session_max: cfg.session_tool_call_max,
turn_count: 0,
rate_deque: VecDeque::new(),
session_count: 0,
}
}
fn begin_turn(&mut self) {
self.turn_count = 0;
}
fn admit(&mut self, now: Instant) -> GateDecision {
if self.turn_count >= self.turn_limit {
return GateDecision::TurnLimitExceeded;
}
if let Some(rate) = self.rate {
let cutoff = now.checked_sub(rate.window).unwrap_or(now);
while self.rate_deque.front().is_some_and(|t| *t < cutoff) {
self.rate_deque.pop_front();
}
if self.rate_deque.len() >= rate.calls {
return GateDecision::RateLimitExceeded;
}
}
if let Some(max) = self.session_max
&& self.session_count >= max
{
return GateDecision::SessionExhausted;
}
self.turn_count += 1;
self.session_count += 1;
if self.rate.is_some() {
self.rate_deque.push_back(now);
}
GateDecision::Proceed
}
}
fn normalise_pending_free_approve(last: Option<InvocationEndReason>) -> Continuation {
if last == Some(InvocationEndReason::TransportError) {
Continuation::Retry
} else {
Continuation::Prompt(RESUME_PROMPT.to_string())
}
}
fn drive(
session: &mut Session,
executor: &ToolExecutor,
cfg: &TellConfig,
cont: Continuation,
counters: &mut Counters,
) -> io::Result<Driven> {
let cont = match cont {
Continuation::Approve if session.load_pending()?.is_some() => Continuation::Approve,
Continuation::Approve => {
let last = session.last_invocation_end_reason()?;
if last == Some(InvocationEndReason::TransportError) {
eprintln!(
"[approve] previous invocation ended in a transport error; re-issuing the same request"
);
}
normalise_pending_free_approve(last)
}
other => other,
};
if matches!(cont, Continuation::Prompt(_)) {
try_auto_compact(session, &cfg.model, counters, cfg.max_tokens)?;
}
let is_prompt = matches!(cont, Continuation::Prompt(_));
let is_retry = matches!(cont, Continuation::Retry);
let is_approve = matches!(cont, Continuation::Approve);
let mut messages = build_initial_messages(session, cfg)?;
if is_prompt || is_retry {
let repaired = repair_orphaned_tool_calls(session, &mut messages)?;
if repaired > 0 {
eprintln!(
"[repair] inserted {repaired} synthetic tool result(s) for unanswered tool_call(s)"
);
}
}
match cont {
Continuation::Prompt(text) => {
session.append(&SessionRecord::User {
ts: now_unix_millis(),
text: text.clone(),
})?;
messages.push(ChatMessage::User(text));
}
Continuation::Approve => {
let pendings = load_pending_or_err(session)?;
let grant = plan_grant(cfg.grant_request, &pendings, executor.root())?;
for pending in &pendings {
session.append(&SessionRecord::ToolApproval {
ts: now_unix_millis(),
call_id: pending.call_id.clone(),
decision: ApprovalDecision::Approve,
auto_decided_by: None,
})?;
let content = execute_pending(pending, executor, command_timeout(cfg))?;
append_tool(session, &mut messages, &pending.call_id, content)?;
}
session.clear_pending()?;
if let Some(intent) = grant {
apply_grant(cfg, &intent);
}
}
Continuation::Retry => {}
}
if is_approve {
let repaired = repair_orphaned_tool_calls(session, &mut messages)?;
if repaired > 0 {
eprintln!(
"[repair] inserted {repaired} synthetic tool result(s) for unanswered tool_call(s)"
);
}
}
let tools = build_tool_defs();
let rules = permissions::load(&cfg.session_name)?;
let permission_layers: Vec<(RuleScope, &[Rule])> = vec![
(RuleScope::Workspace, rules.workspace.as_slice()),
(RuleScope::Session, rules.session.as_slice()),
];
let mut gate = ToolCallGate::new(cfg);
for _ in 0..cfg.max_turns {
gate.begin_turn();
let request = ChatRequest::new(cfg.model.clone(), messages.clone())
.with_tools(tools.clone())
.with_max_tokens(cfg.max_tokens)
.with_temperature(cfg.temperature);
let mut stdout = io::stdout();
let call_result = {
let mut sinks = ProgressSinks {
content: &mut stdout,
};
match curl::call(&request, &mut sinks) {
Ok(r) => r,
Err(e) if e.is_retryable() => {
return Ok(Driven::TransportFailed(format!("model call failed: {e}")));
}
Err(e) => {
return Err(io::Error::other(format!("model call failed: {e}")));
}
}
};
let _ = writeln!(io::stdout());
let assistant = call_result.clone().into_assistant();
session.append(&SessionRecord::Assistant {
ts: now_unix_millis(),
content: call_result.content.clone(),
tool_calls: call_result.tool_calls.clone(),
})?;
counters.turns += 1;
if let Some(usage) = call_result.usage {
session.append(&SessionRecord::TokenUsage {
ts: now_unix_millis(),
body: TokenUsageBody {
prompt_tokens: usage.prompt_tokens,
completion_tokens: usage.completion_tokens,
total_tokens: usage.total_tokens,
prompt_cache_hit_tokens: usage.prompt_cache_hit_tokens,
prompt_cache_miss_tokens: usage.prompt_cache_miss_tokens,
},
})?;
counters.prompt_tokens_last = usage.prompt_tokens.unwrap_or(0);
counters.prompt_tokens_billed_total = counters
.prompt_tokens_billed_total
.saturating_add(usage.prompt_tokens.unwrap_or(0));
counters.completion_tokens_total = counters
.completion_tokens_total
.saturating_add(usage.completion_tokens.unwrap_or(0));
counters.prompt_cache_hit_tokens_total = counters
.prompt_cache_hit_tokens_total
.saturating_add(usage.prompt_cache_hit_tokens.unwrap_or(0));
counters.prompt_cache_miss_tokens_total = counters
.prompt_cache_miss_tokens_total
.saturating_add(usage.prompt_cache_miss_tokens.unwrap_or(0));
}
messages.push(assistant);
if call_result.tool_calls.is_empty() {
return Ok(Driven::Completed);
}
let mut parked: Vec<Pending> = Vec::new();
let mut suspending = false;
for tc in &call_result.tool_calls {
match tc.function_name.as_str() {
"list" => counters.tool_calls_by_kind.list += 1,
"read" => counters.tool_calls_by_kind.read += 1,
"search" => counters.tool_calls_by_kind.search += 1,
"patch" => counters.tool_calls_by_kind.patch += 1,
"command" => counters.tool_calls_by_kind.command += 1,
_ => counters.tool_calls_by_kind.unknown += 1,
}
match gate.admit(Instant::now()) {
GateDecision::Proceed => {}
GateDecision::TurnLimitExceeded => {
let content = tool_error_json(
"turn_tool_call_limit_exceeded",
&format!(
"turn_tool_call_limit={} exceeded in this turn",
cfg.turn_tool_call_limit
),
);
eprintln!(
"[cap] turn_tool_call_limit={} exceeded",
cfg.turn_tool_call_limit
);
counters.tool_errors += 1;
append_tool(session, &mut messages, &tc.id, content)?;
continue;
}
GateDecision::RateLimitExceeded => {
let rate = cfg
.tool_call_rate
.expect("rate cap must be Some to hit RateLimitExceeded");
let content = tool_error_json(
"tool_call_rate_exceeded",
&format!(
"tool_call_rate={}/{}s exceeded",
rate.calls,
rate.window.as_secs()
),
);
eprintln!(
"[cap] tool_call_rate={}/{}s exceeded",
rate.calls,
rate.window.as_secs()
);
counters.tool_errors += 1;
append_tool(session, &mut messages, &tc.id, content)?;
continue;
}
GateDecision::SessionExhausted => {
let max = cfg
.session_tool_call_max
.expect("session cap must be Some to hit SessionExhausted");
eprintln!("[cap] session_tool_call_max={max} exhausted; ending invocation");
return Ok(Driven::SessionToolCallExhausted);
}
}
match classify(&tc.function_name) {
ToolKind::ReadOnly => {
if suspending {
} else {
match run_read_only(tc, executor) {
ReadOnlyDispatch::Done {
summary,
content,
errored,
} => {
eprintln!("{summary}");
if errored {
counters.tool_errors += 1;
}
append_tool(session, &mut messages, &tc.id, content)?;
}
ReadOnlyDispatch::NeedsApproval { summary, preview } => {
eprintln!("{summary}");
parked.push(build_pending(tc, PendingToolKind::Read, preview));
suspending = true;
}
}
}
}
ToolKind::Patch => {
if let PatchDispatch::Awaiting(pending) = dispatch_patch_unapproved(
tc,
executor,
&permission_layers,
&cfg.authorization,
session,
&mut messages,
counters,
suspending,
)? {
parked.push(pending);
suspending = true;
}
}
ToolKind::Command => {
if let CommandDispatch::Awaiting(pending) = dispatch_command(
tc,
executor,
&permission_layers,
&cfg.authorization,
session,
&mut messages,
counters,
suspending,
command_timeout(cfg),
)? {
parked.push(pending);
suspending = true;
}
}
ToolKind::Unknown => {
if suspending {
} else {
let content = tool_error_json(
"unknown_tool",
&format!("no such tool: {}", tc.function_name),
);
eprintln!("[unknown tool] {}", tc.function_name);
counters.tool_errors += 1;
append_tool(session, &mut messages, &tc.id, content)?;
}
}
}
}
if !parked.is_empty() {
session.save_pending(&parked)?;
return Ok(Driven::AwaitingApproval);
}
}
Err(io::Error::other(max_turns_error(cfg.max_turns)))
}
fn max_turns_error(max_turns: usize) -> String {
format!(
"tell loop exceeded max_turns={max_turns}; continue this session? \
run the following command:\n\
attini approve # or give a new instruction with: attini tell '...'"
)
}
fn canonicalise_extra_read_roots(
workspace_root: &std::path::Path,
candidates: Vec<PathBuf>,
) -> Vec<PathBuf> {
let mut seen = std::collections::BTreeSet::<PathBuf>::new();
let mut out = Vec::new();
for p in candidates {
let absolute = if p.is_absolute() {
p.clone()
} else {
workspace_root.join(&p)
};
match absolute.canonicalize() {
Ok(canon) => {
if seen.insert(canon.clone()) {
out.push(canon);
}
}
Err(e) => eprintln!("attini: extra_read_paths: skipping {}: {e}", p.display()),
}
}
out
}
fn build_initial_messages(session: &Session, cfg: &TellConfig) -> io::Result<Vec<ChatMessage>> {
let mut messages = Vec::new();
let summaries = session.load_summaries()?;
let total = summaries.len();
for (i, summary) in summaries.into_iter().enumerate() {
let header = if total > 1 {
format!(
"# Prior conversation summary (part {} of {})\n\n",
i + 1,
total
)
} else {
"# Prior conversation summary\n\n".to_string()
};
messages.push(ChatMessage::System(format!("{header}{}", summary.text)));
}
if let Some(sys) = &cfg.system_prompt {
messages.push(ChatMessage::System(sys.clone()));
}
messages.push(ChatMessage::System(render_scratchpad_note(
&cfg.session_name,
)));
messages.push(ChatMessage::System(render_tool_batching_note()));
for record in session.load_records_since_last_summary()? {
messages.push(record.message);
}
Ok(messages)
}
fn render_scratchpad_note(session_name: &str) -> String {
format!(
"# Working notes\n\n\
You may keep working notes / scratchpad files under \
`.attini/{session_name}/scratchpad/` (relative to the workspace root). \
This per-session directory is not tracked by git and never appears in \
`git diff`. Use it for checklists, intermediate findings, or step lists \
that would otherwise clutter the conversation. Because files there are \
not tracked, `patch` writes are permitted but are shown for approval, \
like any other non-tracked write.\n"
)
}
fn render_tool_batching_note() -> String {
"# Tool call batching\n\n\
You may emit several tool calls in one turn. However, if any of them \
requires human approval — a `command`, or a `patch` on a non-tracked \
path — place it **last** in the turn, or emit it alone. Any tool call \
ordered after an approval-gated one (including a read-only `read`, \
`search`, or `list`) is left unanswered and cancelled on resume, so \
you would have to reissue it. Read-only calls may be freely batched \
together, and may precede an approval-gated call; just do not put \
them after one.\n"
.to_string()
}
const SUMMARIZER_SYSTEM_PROMPT: &str = "You are summarizing a conversation between a user and a coding agent \
so the agent can continue with a shorter context. Preserve:\n\
\n\
- Unfinished tasks and any next steps the user or agent laid out\n\
- Decisions reached (chosen approaches; rejected alternatives with the reason)\n\
- File paths and key symbols (functions, types) that were read, modified,\n\
or discussed\n\
- Recent errors and their root cause, if any\n\
\n\
Aim for ~500 words of plain prose. Do not include markdown code fences \
unless quoting a short critical excerpt. Do not comment on the \
summarization itself; produce only the summary.";
fn should_auto_compact(latest: u64, total_chars: usize) -> bool {
latest >= COMPACTION_TRIGGER_TOKENS || total_chars > RECORDS_TOTAL_MAX_CHARS
}
fn try_auto_compact(
session: &mut Session,
model: &str,
counters: &mut Counters,
max_tokens: Option<u64>,
) -> io::Result<()> {
if session.load_pending()?.is_some() {
return Ok(());
}
if let Err(e) = maybe_prune_conversation(session) {
eprintln!("[prune] skipped: {e}");
}
let latest = session.latest_prompt_tokens()?.unwrap_or(0);
let total_chars = if latest < COMPACTION_TRIGGER_TOKENS {
let records = session.load_records_since_last_summary()?;
records
.iter()
.map(|r| message_raw_char_len(&r.message) + 1)
.sum::<usize>()
} else {
0
};
if !should_auto_compact(latest, total_chars) {
return Ok(());
}
if latest < COMPACTION_TRIGGER_TOKENS {
eprintln!(
"[compaction] previous prompt was {latest} tokens (below threshold) but records are \
{total_chars} chars, summarising..."
);
} else {
eprintln!(
"[compaction] previous prompt was {latest} tokens (threshold {COMPACTION_TRIGGER_TOKENS}), summarising..."
);
}
counters.compaction_attempts += 1;
if let Err(e) = compact_conversation(session, model, max_tokens) {
counters.compaction_failures += 1;
eprintln!("[compaction] failed, continuing with full history: {e}");
}
Ok(())
}
pub fn compact_conversation(
session: &mut Session,
model: &str,
max_tokens: Option<u64>,
) -> io::Result<()> {
let records = session.load_records_since_last_summary()?;
let Some(keep_start) = compaction_cutoff(
&records,
KEEP_RECENT_RECORDS_TARGET,
RETAINED_TAIL_MAX_CHARS,
) else {
eprintln!("[compaction] no records eligible for summarisation. skipping.");
return Ok(());
};
let to_summarise: Vec<ChatMessageWithTs> = if keep_start == records.len() {
records.clone()
} else {
records[..keep_start].to_vec()
};
let record_count = to_summarise.len();
let since_ts = to_summarise
.first()
.map(|r| r.ts)
.expect("to_summarise is non-empty");
let cutoff_ts = to_summarise
.last()
.map(|r| r.ts)
.expect("to_summarise is non-empty");
let text = run_summariser(model, to_summarise, max_tokens)?;
let words = text.split_whitespace().count();
session.append(&SessionRecord::Summary {
ts: now_unix_millis(),
since_ts,
cutoff_ts,
text,
})?;
eprintln!("[compaction] applied. summarised {record_count} records into ~{words} words.");
Ok(())
}
fn maybe_prune_conversation(session: &Session) -> io::Result<Option<PruneStats>> {
let path = session.conversation_path();
let orig_size = match std::fs::metadata(path) {
Ok(m) => m.len(),
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e),
};
if orig_size <= CONVERSATION_PRUNE_TRIGGER_BYTES {
return Ok(None);
}
let Some((offset, dropped)) = prune_offset_past_midpoint(path, orig_size)? else {
return Ok(None);
};
if offset == 0 {
return Ok(None);
}
rewrite_file_from_offset(path, offset)?;
let new_size = std::fs::metadata(path)?.len();
eprintln!(
"[prune] dropped {dropped} records, {orig_size} -> {new_size} bytes (file crossed \
{CONVERSATION_PRUNE_TRIGGER_BYTES} bytes)"
);
Ok(Some(PruneStats {
dropped_records: dropped,
orig_size,
new_size,
}))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct PruneStats {
dropped_records: u64,
orig_size: u64,
new_size: u64,
}
fn prune_offset_past_midpoint(
path: &std::path::Path,
orig_size: u64,
) -> io::Result<Option<(u64, u64)>> {
use std::io::BufRead;
let file = std::fs::File::open(path)?;
let mut reader = std::io::BufReader::new(file);
let midpoint = orig_size / 2;
let mut offset: u64 = 0;
let mut line_index: u64 = 0;
let mut line = String::new();
loop {
line.clear();
let start = offset;
let read_bytes = reader.read_line(&mut line)?;
if read_bytes == 0 {
break;
}
offset += read_bytes as u64;
if !line.trim().is_empty() {
if start >= midpoint && line_is_safe_boundary(&line) {
return Ok(Some((start, line_index)));
}
line_index += 1;
}
}
Ok(None)
}
fn line_is_safe_boundary(line: &str) -> bool {
let json = match RawJson::parse(line) {
Ok(j) => j,
Err(_) => return false,
};
let value = json.value();
let kind = value
.to_member("kind")
.and_then(|m| m.required())
.and_then(|m| m.to_unquoted_string_str());
match kind {
Ok(ref k) if k.as_ref() == "user" => true,
Ok(ref k) if k.as_ref() == "assistant" => {
let has_text = value
.to_member("text")
.and_then(|m| m.required())
.ok()
.and_then(|t| t.to_unquoted_string_str().ok())
.map(|s| !s.trim().is_empty())
.unwrap_or(false);
if has_text {
return true;
}
let has_calls = value
.to_member("tool_calls")
.and_then(|m| m.required())
.ok()
.and_then(|tc| tc.to_array().ok())
.map(|mut a| a.next().is_some())
.unwrap_or(false);
!has_calls
}
_ => false,
}
}
fn rewrite_file_from_offset(path: &std::path::Path, offset: u64) -> io::Result<()> {
use std::io::{Read, Seek, SeekFrom};
let mut file = std::fs::File::open(path)?;
file.seek(SeekFrom::Start(offset))?;
let mut buf = Vec::new();
file.read_to_end(&mut buf)?;
drop(file);
let tmp = path.with_extension("jsonl.prune-tmp");
let _ = std::fs::remove_file(&tmp);
{
let mut out = std::fs::File::create(&tmp)?;
out.write_all(&buf)?;
out.sync_all()?;
}
std::fs::rename(&tmp, path)
}
const ASK_SYSTEM_PROMPT: &str = "You are an OUTSIDE observer reading a recording of a \
coding-agent session. You are NOT the coding assistant and you are NOT continuing \
its work: do not emit tool calls, do not write plan steps, do not pick up an \
unfinished action, do not reproduce the session's agentic wording. Read the session \
transcript below (rendered as plain prose; tool calls and results are abbreviated) \
and answer directly and concisely about what is happening now: unfinished work, \
decisions, files/symbols in play, and any tool call awaiting approval. If a question \
is appended, answer that question specifically. Otherwise produce a short status \
summary (~300 words) of the current state, in third person. Never begin with an \
action verb such as 'I will / I am going to / let's'. Do not comment on the \
instruction itself; produce only the answer. Answer in the same language the session \
transcript is written in (if the transcript mixes languages, use its dominant \
language), even when no question is appended. A prior observer answer may be included \
below as context: treat it as a hint only and always let the transcript below \
override it.";
fn abbreviate_args(args_json: &str) -> String {
let t = args_json.trim();
if t.is_empty() {
return String::new();
}
let mut cleaned = String::new();
for c in t.chars().take(90) {
cleaned.push(if c == '\n' { ' ' } else { c });
}
if t.chars().count() > 90 {
cleaned.push('…');
}
cleaned
}
fn render_records_prose(records: &[ChatMessageWithTs]) -> String {
let mut out = String::new();
for rec in records {
match &rec.message {
ChatMessage::System(s) => out.push_str(&format!("[system] {}\n", s.trim())),
ChatMessage::User(s) => out.push_str(&format!("user: {}\n", s.trim())),
ChatMessage::Assistant {
content,
tool_calls,
..
} => {
let c = content.trim();
let mut lines = Vec::new();
if !c.is_empty() {
lines.push(c.to_string());
}
for tc in tool_calls {
let a = abbreviate_args(&tc.arguments_json);
if a.is_empty() {
lines.push(format!(" [tool call: {}]", tc.function_name));
} else {
lines.push(format!(" [tool call: {} ({})]", tc.function_name, a));
}
}
if !lines.is_empty() {
out.push_str(&format!("assistant: {}\n", lines.join("\n")));
}
}
ChatMessage::Tool { content, .. } => {
let t = content.trim();
let brief = if t.is_empty() {
String::new()
} else {
let mut s = String::new();
for c in t.chars().take(200) {
s.push(c);
}
if t.chars().count() > 200 {
s.push('…');
}
s
};
out.push_str(&format!(" [tool result: {}]\n", brief));
}
}
out.push('\n');
}
out
}
fn truncate_prose(s: &str, max_chars: usize) -> String {
let count = s.chars().count();
if count <= max_chars {
return s.to_string();
}
let head: String = s.chars().take(max_chars).collect();
format!("{head}…[truncated {} chars]", count - max_chars)
}
fn render_summary_block(rec: &ChatMessageWithTs) -> String {
match &rec.message {
ChatMessage::System(s) => format!(
"[system] {}\n",
truncate_prose(s.trim(), SUMMARY_RECORD_MAX_CHARS)
),
ChatMessage::User(s) => format!(
"user: {}\n",
truncate_prose(s.trim(), SUMMARY_RECORD_MAX_CHARS)
),
ChatMessage::Assistant {
content,
tool_calls,
..
} => {
let mut lines = Vec::new();
let c = content.trim();
if !c.is_empty() {
lines.push(truncate_prose(c, SUMMARY_RECORD_MAX_CHARS));
}
for tc in tool_calls {
let a = abbreviate_args(&tc.arguments_json);
if a.is_empty() {
lines.push(format!(" [tool call: {}]", tc.function_name));
} else {
lines.push(format!(" [tool call: {} ({})]", tc.function_name, a));
}
}
if lines.is_empty() {
String::new()
} else {
format!("assistant: {}\n", lines.join("\n"))
}
}
ChatMessage::Tool { content, .. } => {
let t = content.trim();
let brief = truncate_prose(t, SUMMARY_TOOL_RESULT_MAX_CHARS);
format!(" [tool result: {}]\n", brief)
}
}
}
fn render_summary_transcript(records: &[ChatMessageWithTs]) -> String {
let blocks: Vec<String> = records.iter().map(render_summary_block).collect();
let mut kept: Vec<String> = Vec::new();
let mut total = 0usize;
let mut dropped_oldest = false;
for block in blocks.iter().rev() {
let len = block.chars().count();
if total + len > SUMMARY_MAX_CHARS {
dropped_oldest = true;
break;
}
kept.push(block.clone());
total += len;
}
let mut out = String::new();
if dropped_oldest {
out.push_str(
"[Note: the earliest records of this segment were dropped to fit the \
summariser's context window; the transcript below is the most recent \
portion, so the summary should reflect the current state.]\n\n",
);
}
for block in kept.into_iter().rev() {
out.push_str(&block);
out.push('\n');
}
out
}
fn call_summariser_messages(
model: &str,
messages: Vec<ChatMessage>,
max_tokens: Option<u64>,
) -> io::Result<String> {
let request = ChatRequest::new(model.to_string(), messages).with_max_tokens(max_tokens);
let mut sink = io::sink();
let mut sinks = ProgressSinks { content: &mut sink };
let result = curl::call(&request, &mut sinks)
.map_err(|e| io::Error::other(format!("summariser call failed: {e}")))?;
pick_summary_text(&result).ok_or_else(|| io::Error::other("summariser returned empty content"))
}
fn run_summariser(
model: &str,
records: Vec<ChatMessageWithTs>,
max_tokens: Option<u64>,
) -> io::Result<String> {
let transcript = render_summary_transcript(&records);
let messages = vec![
ChatMessage::System(SUMMARIZER_SYSTEM_PROMPT.to_string()),
ChatMessage::User(transcript),
];
call_summariser_messages(model, messages, max_tokens)
}
pub(crate) fn run_ask_summary(
records: Vec<ChatMessageWithTs>,
model: &str,
question: Option<&str>,
prior: Option<&str>,
max_tokens: Option<u64>,
) -> io::Result<String> {
let mut system = ASK_SYSTEM_PROMPT.to_string();
if let Some(p) = prior {
system.push_str(
"\n\n--- PREVIOUS ask context (an EARLIER observer answer; it is a HINT, not \
ground truth \u{2014} the transcript below is authoritative) ---\n\n",
);
system.push_str(p);
system.push_str("\n\n--- END PREVIOUS ask context ---\n");
}
if let Some(q) = question {
system.push_str("\n\nThe user's question is: ");
system.push_str(q);
system.push('\n');
}
system.push_str("\n\n--- BEGIN SESSION TRANSCRIPT (prose) ---\n\n");
system.push_str(&render_records_prose(&records));
system.push_str("\n--- END SESSION TRANSCRIPT ---\n");
call_summariser_messages(model, vec![ChatMessage::System(system)], max_tokens)
}
fn pick_summary_text(result: &curl::CallResult) -> Option<String> {
let content = result.content.trim();
if content.is_empty() {
return None;
}
Some(content.to_string())
}
fn safe_tail_start(records: &[ChatMessageWithTs], target_keep: usize) -> usize {
let n = records.len();
if n <= target_keep {
return 0;
}
let mut i = n - target_keep;
while i < n {
if is_safe_boundary(&records[i].message) {
return i;
}
i += 1;
}
0
}
fn is_safe_boundary(msg: &ChatMessage) -> bool {
match msg {
ChatMessage::User(_) => true,
ChatMessage::Assistant { tool_calls, .. } => tool_calls.is_empty(),
_ => false,
}
}
fn message_raw_char_len(msg: &ChatMessage) -> usize {
match msg {
ChatMessage::System(s) => s.len(),
ChatMessage::User(s) => s.len(),
ChatMessage::Assistant {
content,
tool_calls,
} => {
content.len()
+ tool_calls
.iter()
.map(|tc| tc.function_name.len() + tc.arguments_json.len())
.sum::<usize>()
}
ChatMessage::Tool { content, .. } => content.len(),
}
}
fn compaction_cutoff(
records: &[ChatMessageWithTs],
target_keep: usize,
max_tail_chars: usize,
) -> Option<usize> {
let n = records.len();
let mut keep_start = safe_tail_start(records, target_keep);
while keep_start < n {
let tail_chars: usize = records[keep_start..]
.iter()
.map(|r| message_raw_char_len(&r.message))
.sum();
if tail_chars <= max_tail_chars {
break;
}
let mut next = keep_start + 1;
while next < n && !is_safe_boundary(&records[next].message) {
next += 1;
}
keep_start = next;
}
if keep_start == 0 {
return None;
}
Some(keep_start)
}
fn build_tool_defs() -> Vec<ToolDef> {
let mut defs = ReadOnlyTool::definitions();
defs.push(PatchInvocation::definition());
defs.push(CommandInvocation::definition());
defs
}
enum CommandDispatch {
Awaiting(Pending),
Continue,
}
#[expect(
clippy::too_many_arguments,
reason = "the dispatcher threads the shared tool-call context (session, messages, counters, \
rules, executor) through in one call to keep dispatch borrows in one place"
)]
fn dispatch_command(
tc: &ToolCall,
executor: &ToolExecutor,
layers: &[(RuleScope, &[Rule])],
authorization: &Authorization,
session: &mut Session,
messages: &mut Vec<ChatMessage>,
counters: &mut Counters,
dry_run: bool,
timeout: Option<Duration>,
) -> io::Result<CommandDispatch> {
let inv = match CommandInvocation::parse(&tc.arguments_json) {
Ok(inv) => inv,
Err(err) => {
if !dry_run {
let msg = err.message();
let content = tool_error_json("command_args", &msg);
eprintln!("[command] parse err: {msg}");
counters.tool_errors += 1;
append_tool(session, messages, &tc.id, content)?;
}
return Ok(CommandDispatch::Continue);
}
};
let judgment = evaluate(layers, &inv.argv, authorization);
let display = shell_escape_argv(&inv.argv);
match judgment {
Judgment::AutoApprove(dec) => {
if !dry_run {
let dec_display = shell_escape_argv(&dec.args_prefix);
eprintln!(
"[command] auto-approve via {} rule '{}': {}",
dec.scope.as_str(),
dec_display,
display
);
append_auto_approval(session, &tc.id, ApprovalDecision::Approve, &dec)?;
let content = match run_command_sync(&inv, executor, timeout) {
Ok(s) => s,
Err(err) => {
let (code, msg) = err.to_code_and_message();
counters.tool_errors += 1;
let payload = tool_error_json(code, &msg);
append_tool(session, messages, &tc.id, payload)?;
return Ok(CommandDispatch::Continue);
}
};
append_tool(session, messages, &tc.id, content)?;
}
Ok(CommandDispatch::Continue)
}
Judgment::AutoDeny(dec) => {
if !dry_run {
let dec_display = shell_escape_argv(&dec.args_prefix);
eprintln!(
"[command] auto-deny via {} rule '{}': {}",
dec.scope.as_str(),
dec_display,
display
);
append_auto_approval(session, &tc.id, ApprovalDecision::Reject, &dec)?;
let content = tool_error_json(
"denied_by_rule",
&format!(
"auto-denied by {} rule args_prefix {:?}",
dec.scope.as_str(),
dec.args_prefix
),
);
counters.tool_errors += 1;
append_tool(session, messages, &tc.id, content)?;
}
Ok(CommandDispatch::Continue)
}
Judgment::Pending => {
let preview_text = render_command_preview_from(&inv);
eprintln!("[command] approval required");
eprintln!("{preview_text}");
emit_suggested_rule(&inv.argv);
Ok(CommandDispatch::Awaiting(build_pending(
tc,
PendingToolKind::Command,
preview_text,
)))
}
}
}
fn append_auto_approval(
session: &mut Session,
call_id: &str,
decision: ApprovalDecision,
dec: &AutoDecision,
) -> io::Result<()> {
let sidecar = AutoDecidedBy {
scope: dec.scope.as_str().to_string(),
args_prefix: dec.args_prefix.clone(),
allow: dec.allowed,
matches: dec
.matches
.iter()
.map(|m| AutoDecidedMatch {
scope: m.scope.as_str().to_string(),
kind: m.kind.as_str().to_string(),
allow: m.allow,
args_prefix: m.args_prefix.clone(),
path: m.path.clone(),
adopted: m.adopted,
})
.collect(),
};
session.append(&SessionRecord::ToolApproval {
ts: now_unix_millis(),
call_id: call_id.to_string(),
decision,
auto_decided_by: Some(sidecar),
})
}
fn emit_suggested_rule(argv: &[String]) {
let Some(prefix) = grant_prefix(argv) else {
return;
};
let prefix_display = shell_escape_argv(&prefix);
eprintln!("suggested rule (fold into the next approve):");
eprintln!(" attini approve --grant session # allow {prefix_display} (session-local)");
eprintln!(" attini approve --grant workspace # allow {prefix_display} (workspace-wide)");
}
fn grant_prefix(argv: &[String]) -> Option<Vec<String>> {
if argv.is_empty() {
return None;
}
let take = argv.len().min(2);
Some(argv[..take].to_vec())
}
#[derive(Debug)]
enum GrantIntent {
Command(Vec<String>),
Read(String),
Write(String),
}
fn plan_grant(
request: GrantRequest,
pendings: &[Pending],
workspace_root: &std::path::Path,
) -> io::Result<Option<GrantIntent>> {
match request {
GrantRequest::None | GrantRequest::Oneshot => return Ok(None),
GrantRequest::Session | GrantRequest::Workspace => {}
}
let [pending] = pendings else {
return Err(io::Error::other(
"--grant is ambiguous with multiple pending calls; approve one at a time".to_string(),
));
};
match pending.tool_kind {
PendingToolKind::Command => {
let inv = CommandInvocation::parse(&pending.arguments_json).map_err(|e| {
io::Error::other(format!(
"--grant: could not read the pending command: {e:?}"
))
})?;
match grant_prefix(&inv.argv) {
Some(prefix) => Ok(Some(GrantIntent::Command(prefix))),
None => Err(io::Error::other(
"--grant: the pending command has no argv to persist".to_string(),
)),
}
}
PendingToolKind::Read => {
let inv = ReadOnlyTool::parse(&pending.function_name, &pending.arguments_json)
.map_err(|e| {
io::Error::other(format!("--grant: could not read the pending read: {e:?}"))
})?;
match read_extra_root(&inv, workspace_root) {
Some(path) => Ok(Some(GrantIntent::Read(grant_read_path(
&path,
workspace_root,
)))),
None => Err(io::Error::other(
"--grant: the pending read has no resolvable path to persist".to_string(),
)),
}
}
PendingToolKind::Patch => {
let inv = PatchInvocation::parse(&pending.arguments_json).map_err(|e| {
io::Error::other(format!("--grant: could not read the pending patch: {e:?}"))
})?;
let [edit] = inv.edits.as_slice() else {
return Err(io::Error::other(
"--grant is ambiguous for a patch touching multiple paths; approve one at a time"
.to_string(),
));
};
let target = edit.path();
match workspace_relative_write_target(target, workspace_root) {
Some(rel) => Ok(Some(GrantIntent::Write(rel))),
None => Ok(Some(GrantIntent::Write((*target).to_string()))),
}
}
}
}
fn apply_grant(cfg: &TellConfig, intent: &GrantIntent) {
let scope = match cfg.grant_request {
GrantRequest::Session => permissions::GrantScope::Session(&cfg.session_name),
GrantRequest::Workspace => permissions::GrantScope::Workspace,
GrantRequest::None | GrantRequest::Oneshot => return,
};
let outcome = match intent {
GrantIntent::Command(argv_prefix) => permissions::grant(scope, argv_prefix),
GrantIntent::Read(path) => permissions::grant_read(scope, path),
GrantIntent::Write(path) => permissions::grant_write(scope, path),
};
let display = match intent {
GrantIntent::Command(argv_prefix) => shell_escape_argv(argv_prefix),
GrantIntent::Read(path) => path.clone(),
GrantIntent::Write(path) => path.clone(),
};
match outcome {
Ok(permissions::GrantOutcome::Appended(path)) => {
eprintln!(
"[approve] granted: appended '{display}' to {}",
path.display()
);
}
Ok(permissions::GrantOutcome::AlreadyGranted(path)) => {
eprintln!("[approve] already granted (no-op): {}", path.display());
}
Err(e) => {
eprintln!("[approve] warning: grant of '{display}' failed: {e}; approval still stands");
}
}
}
pub(crate) fn shell_single_quote(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('\'');
for c in s.chars() {
if c == '\'' {
out.push_str("'\\''");
} else {
out.push(c);
}
}
out.push('\'');
out
}
fn shell_escape_argv(argv: &[String]) -> String {
argv.iter()
.map(|s| {
if s.is_empty() || s.chars().any(needs_shell_quote) {
shell_single_quote(s)
} else {
s.clone()
}
})
.collect::<Vec<_>>()
.join(" ")
}
fn needs_shell_quote(c: char) -> bool {
matches!(
c,
' ' | '\t'
| '\n'
| '|'
| '&'
| ';'
| '('
| ')'
| '$'
| '`'
| '>'
| '<'
| '\\'
| '"'
| '\''
| '*'
| '?'
| '['
| ']'
| '{'
| '}'
| '!'
| '#'
| '~'
| '='
)
}
fn render_command_preview_from(inv: &CommandInvocation) -> String {
format!("command preview: {}", shell_escape_argv(&inv.argv))
}
enum ToolKind {
ReadOnly,
Patch,
Command,
Unknown,
}
fn classify(name: &str) -> ToolKind {
match name {
"list" | "read" | "search" => ToolKind::ReadOnly,
"patch" => ToolKind::Patch,
"command" => ToolKind::Command,
_ => ToolKind::Unknown,
}
}
enum ReadOnlyDispatch {
Done {
summary: String,
content: String,
errored: bool,
},
NeedsApproval { summary: String, preview: String },
}
fn run_read_only(tc: &ToolCall, executor: &ToolExecutor) -> ReadOnlyDispatch {
match ReadOnlyTool::parse(&tc.function_name, &tc.arguments_json) {
Ok(inv) => {
let args_summary = summarize_read_only(&inv);
match executor.execute(inv.clone()) {
ToolOutcome::Ok(payload) => {
let mut summary = format!("[{args_summary}] ok");
if let Some(preview) = read_content_preview(&payload) {
summary.push('\n');
summary.push_str(&preview);
}
ReadOnlyDispatch::Done {
summary,
content: payload,
errored: false,
}
}
ToolOutcome::Err(ToolExecutionError::OutsideWorkspace) => {
let preview = format!(r#"{args_summary} (outside workspace)"#);
ReadOnlyDispatch::NeedsApproval {
summary: format!("[{args_summary}] approval required"),
preview,
}
}
ToolOutcome::Err(err) => ReadOnlyDispatch::Done {
summary: format!("[{args_summary}] err: {}", err.message()),
content: tool_error_json_from(&err),
errored: true,
},
}
}
Err(err) => ReadOnlyDispatch::Done {
summary: format!("[{}] parse err: {}", tc.function_name, err.message()),
content: tool_error_json_from(&err),
errored: true,
},
}
}
fn read_only_target(inv: &ReadOnlyTool) -> Option<&str> {
match inv {
ReadOnlyTool::List { path, .. } => Some(path),
ReadOnlyTool::Read { path, .. } => Some(path),
ReadOnlyTool::Search { path_prefix, .. } => path_prefix.as_deref(),
}
}
fn grant_read_path(canonical: &Path, workspace_root: &Path) -> String {
let root = workspace_root
.canonicalize()
.unwrap_or_else(|_| workspace_root.to_path_buf());
match canonical.strip_prefix(&root) {
Ok(rel) => rel.to_string_lossy().into_owned(),
Err(_) => canonical.display().to_string(),
}
}
fn read_extra_root(inv: &ReadOnlyTool, workspace_root: &Path) -> Option<PathBuf> {
let target = read_only_target(inv)?;
let candidate = if std::path::Path::new(target).is_absolute() {
PathBuf::from(target)
} else {
workspace_root.join(target)
};
candidate.canonicalize().ok()
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum WriteVerdict {
Allowed,
Denied,
Undecided,
}
fn patch_write_verdict(
inv: &PatchInvocation,
permission_layers: &[(RuleScope, &[Rule])],
authorization: &Authorization,
workspace_root: &Path,
) -> WriteVerdict {
let mut all_allowed = true;
for edit in &inv.edits {
let target = match workspace_relative_write_target(edit.path(), workspace_root) {
Some(rel) => PathBuf::from(rel),
None => match canonical_write_target(edit.path(), workspace_root) {
Some(abs) => abs,
None => {
all_allowed = false;
continue;
}
},
};
match evaluate_write(permission_layers, &target, authorization) {
Judgment::AutoDeny(_) => return WriteVerdict::Denied,
Judgment::AutoApprove(_) => {}
Judgment::Pending => all_allowed = false,
}
}
if all_allowed {
WriteVerdict::Allowed
} else {
WriteVerdict::Undecided
}
}
fn workspace_relative_write_target(target: &str, workspace_root: &Path) -> Option<String> {
let candidate = if Path::new(target).is_absolute() {
PathBuf::from(target)
} else {
workspace_root.join(target)
};
let canon = candidate.canonicalize().ok()?;
let root = workspace_root.canonicalize().ok()?;
let rel = canon.strip_prefix(&root).ok()?;
Some(rel.to_string_lossy().into_owned())
}
fn canonical_write_target(target: &str, workspace_root: &Path) -> Option<PathBuf> {
let candidate = if Path::new(target).is_absolute() {
PathBuf::from(target)
} else {
workspace_root.join(target)
};
candidate.canonicalize().ok()
}
fn summarize_read_only(inv: &ReadOnlyTool) -> String {
match inv {
ReadOnlyTool::List {
path, recursive, ..
} => {
if *recursive {
format!(r#"list "{path}" recursive"#)
} else {
format!(r#"list "{path}""#)
}
}
ReadOnlyTool::Read { path, line_range } => match line_range {
Some((start, end)) => format!(r#"read "{path}" lines {start}..{end}"#),
None => format!(r#"read "{path}""#),
},
ReadOnlyTool::Search {
pattern,
path_prefix,
..
} => match path_prefix {
Some(prefix) => format!(r#"search "{pattern}" in "{prefix}""#),
None => format!(r#"search "{pattern}""#),
},
}
}
fn read_content_preview(payload: &str) -> Option<String> {
let json = RawJson::parse(payload).ok()?;
let content = json
.value()
.to_member("content")
.and_then(|m| m.required())
.and_then(|m| m.to_unquoted_string_str())
.ok()?;
let mut out = String::new();
let mut shown: usize = 0;
let mut omitted: usize = 0;
for line in content.lines() {
if shown < READ_PREVIEW_MAX_LINES {
out.push_str(" | ");
out.push_str(line);
out.push('\n');
shown += 1;
} else {
omitted += 1;
}
}
if omitted > 0 {
out.push_str(&format!(" | ... ({omitted} more lines omitted)\n"));
}
while out.ends_with('\n') {
out.pop();
}
if out.is_empty() { None } else { Some(out) }
}
enum PatchDispatch {
Awaiting(Pending),
Continue,
}
#[expect(
clippy::too_many_arguments,
reason = "the dispatcher threads the shared tool-call context (session, messages, counters, \
rules, executor) through in one call to keep dispatch borrows in one place"
)]
fn dispatch_patch_unapproved(
tc: &ToolCall,
executor: &ToolExecutor,
permission_layers: &[(RuleScope, &[Rule])],
authorization: &Authorization,
session: &mut Session,
messages: &mut Vec<ChatMessage>,
counters: &mut Counters,
dry_run: bool,
) -> io::Result<PatchDispatch> {
let inv = match PatchInvocation::parse(&tc.arguments_json) {
Ok(inv) => inv,
Err(err) => {
if !dry_run {
let msg = err.message();
let content = tool_error_json("patch_args", &msg);
eprintln!("[patch] parse err: {msg}");
counters.tool_errors += 1;
append_tool(session, messages, &tc.id, content)?;
}
return Ok(PatchDispatch::Continue);
}
};
let (preview_content, preview) = match executor.preview_patch(&inv) {
Ok(x) => x,
Err(e) => {
if !dry_run {
let (code, msg) = e.to_code_and_message();
let content = tool_error_json(code, &msg);
eprintln!("[patch] preview err: {msg}");
counters.tool_errors += 1;
append_tool(session, messages, &tc.id, content)?;
}
return Ok(PatchDispatch::Continue);
}
};
let write_verdict =
patch_write_verdict(&inv, permission_layers, authorization, executor.root());
let auto_approve = match write_verdict {
WriteVerdict::Denied => false,
WriteVerdict::Allowed => true,
WriteVerdict::Undecided => preview.auto_approve,
};
if auto_approve {
if !dry_run {
match executor.apply_patch(&inv, &preview_content) {
Ok(paths) => {
let via = if matches!(write_verdict, WriteVerdict::Allowed) {
"write rule"
} else {
"git-tracked"
};
eprintln!("[patch] auto-approved: {} file(s) ({via})", paths.len());
eprintln!("{}", render_patch_diff(&inv));
append_tool(session, messages, &tc.id, patch_result_json(&paths))?;
}
Err(e) => {
let (code, msg) = e.to_code_and_message();
let content = tool_error_json(code, &msg);
eprintln!("[patch] apply err: {msg}");
counters.tool_errors += 1;
append_tool(session, messages, &tc.id, content)?;
}
}
}
Ok(PatchDispatch::Continue)
} else {
let preview_text = render_patch_preview_text(&preview, &inv);
eprintln!("[patch] approval required");
eprintln!("{preview_text}");
eprintln!("{}", render_patch_approval_footer(&preview));
Ok(PatchDispatch::Awaiting(build_pending(
tc,
PendingToolKind::Patch,
preview_text,
)))
}
}
fn render_patch_approval_footer(p: &PatchPreview) -> String {
format!(
"[patch] approval required: {} edit(s) across {} file(s), +{} / -{} lines",
p.edit_count,
p.target_paths.len(),
p.added_lines,
p.removed_lines
)
}
fn render_patch_preview_text(p: &PatchPreview, inv: &PatchInvocation) -> String {
let mut out = format!(
"patch preview: {} edit(s) across {} file(s), +{} / -{} lines",
p.edit_count,
p.target_paths.len(),
p.added_lines,
p.removed_lines
);
for path in &p.target_paths {
out.push_str("\n ");
out.push_str(path);
}
if let Some(reason) = &p.not_revertible {
out.push_str("\n NOTE: ");
out.push_str(reason);
}
out.push('\n');
out.push_str(&render_patch_diff(inv));
out
}
fn render_patch_diff(inv: &PatchInvocation) -> String {
let mut out = String::new();
let mut shown: usize = 0;
let mut omitted: usize = 0;
let push = |out: &mut String, shown: &mut usize, omitted: &mut usize, line: String| {
if *shown < PATCH_PREVIEW_MAX_LINES {
out.push_str(&line);
out.push('\n');
*shown += 1;
} else {
*omitted += 1;
}
};
for edit in &inv.edits {
match edit {
PatchTool::Add { path, content } => {
push(&mut out, &mut shown, &mut omitted, format!(" add {path}"));
for line in content.lines() {
push(&mut out, &mut shown, &mut omitted, format!(" + {line}"));
}
}
PatchTool::Update {
path,
before,
after,
} => {
push(
&mut out,
&mut shown,
&mut omitted,
format!(" update {path}"),
);
for line in before.lines() {
push(&mut out, &mut shown, &mut omitted, format!(" - {line}"));
}
for line in after.lines() {
push(&mut out, &mut shown, &mut omitted, format!(" + {line}"));
}
}
}
}
if omitted > 0 {
out.push_str(&format!(" ... ({omitted} more lines omitted)\n"));
}
out
}
fn build_pending(tc: &ToolCall, kind: PendingToolKind, preview: String) -> Pending {
Pending {
ts: now_unix_millis(),
call_id: tc.id.clone(),
tool_kind: kind,
function_name: tc.function_name.clone(),
arguments_json: tc.arguments_json.clone(),
preview,
}
}
fn execute_pending(
pending: &Pending,
executor: &ToolExecutor,
timeout: Option<Duration>,
) -> io::Result<String> {
match pending.tool_kind {
PendingToolKind::Patch => {
let inv = match PatchInvocation::parse(&pending.arguments_json) {
Ok(inv) => inv,
Err(e) => return Ok(tool_error_json("patch_args", &e.message())),
};
let (preview_content, _preview) = match executor.preview_patch(&inv) {
Ok(x) => x,
Err(e) => return Ok(tool_error_json_from_patch(&e)),
};
match executor.apply_patch(&inv, &preview_content) {
Ok(paths) => Ok(patch_result_json(&paths)),
Err(e) => Ok(tool_error_json_from_patch(&e)),
}
}
PendingToolKind::Command => {
let inv = CommandInvocation::parse(&pending.arguments_json)
.map_err(|e| io::Error::other(format!("command args: {e:?}")))?;
match run_command_sync(&inv, executor, timeout) {
Ok(s) => Ok(s),
Err(err) => {
let (code, msg) = err.to_code_and_message();
Ok(tool_error_json(code, &msg))
}
}
}
PendingToolKind::Read => {
let inv = match ReadOnlyTool::parse(&pending.function_name, &pending.arguments_json) {
Ok(inv) => inv,
Err(e) => return Ok(tool_error_json_from(&e)),
};
let Some(extra) = read_extra_root(&inv, executor.root()) else {
return Ok(tool_error_json(
"read_args",
"approved read has no resolvable path",
));
};
match executor.execute_with_extra_read_root(inv, extra) {
ToolOutcome::Ok(payload) => Ok(payload),
ToolOutcome::Err(e) => Ok(tool_error_json_from(&e)),
}
}
}
}
fn command_timeout(cfg: &TellConfig) -> Option<Duration> {
match cfg.command_timeout_seconds {
Some(0) | None => None,
Some(secs) => Some(Duration::from_secs(secs)),
}
}
fn run_command_sync(
inv: &CommandInvocation,
executor: &ToolExecutor,
timeout: Option<Duration>,
) -> Result<String, CommandError> {
let started = Instant::now();
let mut cmd = Command::new(&inv.argv[0]);
cmd.args(&inv.argv[1..]).current_dir(executor.root());
let output = crate::child_output::run_streamed(&mut cmd, timeout).map_err(|e| {
CommandError::SpawnFailed {
message: e.to_string(),
}
})?;
let elapsed = started.elapsed();
let termination_reason = if output.timed_out {
"timeout"
} else if output.status.code().is_some() {
"exited"
} else {
"signaled"
};
Ok(command_result_json(
&output.stdout,
&output.stderr,
output.status.code(),
termination_reason,
elapsed,
output.truncated,
))
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct OrphanedToolCall {
call_id: String,
content: String,
}
fn repair_messages(messages: &[ChatMessage]) -> (Vec<ChatMessage>, Vec<OrphanedToolCall>) {
let mut tools_by_call: BTreeMap<String, VecDeque<ChatMessage>> = BTreeMap::new();
for msg in messages {
if let ChatMessage::Tool { tool_call_id, .. } = msg {
tools_by_call
.entry(tool_call_id.clone())
.or_default()
.push_back(msg.clone());
}
}
let mut repaired: Vec<ChatMessage> = Vec::with_capacity(messages.len());
let mut orphans: Vec<OrphanedToolCall> = Vec::new();
let mut placed: BTreeSet<String> = BTreeSet::new();
for msg in messages {
match msg {
ChatMessage::Assistant { tool_calls, .. } if !tool_calls.is_empty() => {
repaired.push(msg.clone());
for tc in tool_calls {
if placed.contains(&tc.id) {
continue;
}
if let Some(queue) = tools_by_call.get_mut(&tc.id)
&& let Some(tool_msg) = queue.pop_front()
{
repaired.push(tool_msg);
placed.insert(tc.id.clone());
continue;
}
let content = tool_error_json(
"unanswered_tool_call",
"this tool call was left unapproved and is cancelled before continuing",
);
orphans.push(OrphanedToolCall {
call_id: tc.id.clone(),
content: content.clone(),
});
repaired.push(ChatMessage::Tool {
tool_call_id: tc.id.clone(),
content,
});
placed.insert(tc.id.clone());
}
}
ChatMessage::Tool { tool_call_id, .. } => {
if placed.contains(tool_call_id) {
continue; }
placed.insert(tool_call_id.clone());
repaired.push(msg.clone());
}
_ => repaired.push(msg.clone()),
}
}
(repaired, orphans)
}
fn repair_orphaned_tool_calls(
session: &mut Session,
messages: &mut Vec<ChatMessage>,
) -> io::Result<usize> {
let (repaired, orphans) = repair_messages(messages);
if !orphans.is_empty() {
for orphan in &orphans {
if let Some(pendings) = session.load_pending()?
&& pendings.iter().any(|p| p.call_id == orphan.call_id)
{
session.clear_pending()?;
}
session.append(&SessionRecord::ToolApproval {
ts: now_unix_millis(),
call_id: orphan.call_id.clone(),
decision: ApprovalDecision::Reject,
auto_decided_by: Some(AutoDecidedBy {
scope: "repair".to_string(),
args_prefix: Vec::new(),
allow: false,
matches: Vec::new(),
}),
})?;
session.append(&SessionRecord::Tool {
ts: now_unix_millis(),
call_id: orphan.call_id.clone(),
content: orphan.content.clone(),
})?;
eprintln!(
"[repair] cancelled unanswered tool_call {} (left unapproved)",
orphan.call_id
);
}
}
*messages = repaired;
Ok(orphans.len())
}
fn append_tool(
session: &mut Session,
messages: &mut Vec<ChatMessage>,
call_id: &str,
content: String,
) -> io::Result<()> {
session.append(&SessionRecord::Tool {
ts: now_unix_millis(),
call_id: call_id.to_string(),
content: content.clone(),
})?;
messages.push(ChatMessage::Tool {
tool_call_id: call_id.to_string(),
content,
});
Ok(())
}
fn load_pending_or_err(session: &Session) -> io::Result<Vec<Pending>> {
session
.load_pending()?
.ok_or_else(|| io::Error::other("no pending.json — nothing to approve"))
}
fn tool_error_json_from(err: &ToolExecutionError) -> String {
tool_error_json("execution_error", &err.message())
}
fn tool_error_json_from_patch(err: &PatchError) -> String {
let (code, message) = err.to_code_and_message();
tool_error_json(code, &message)
}
fn tool_error_json(code: &str, message: &str) -> String {
struct Payload<'a> {
code: &'a str,
message: &'a str,
}
impl DisplayJson for Payload<'_> {
fn fmt(&self, f: &mut nojson::JsonFormatter<'_, '_>) -> std::fmt::Result {
f.object(|f| {
f.member("error", self.code)?;
f.member("message", self.message)
})
}
}
nojson::Json(Payload { code, message }).to_string()
}
fn patch_result_json(applied: &[PathBuf]) -> String {
struct Payload<'a> {
applied: &'a [PathBuf],
}
impl DisplayJson for Payload<'_> {
fn fmt(&self, f: &mut nojson::JsonFormatter<'_, '_>) -> std::fmt::Result {
f.object(|f| {
f.member("ok", true)?;
f.member(
"applied",
self.applied
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>(),
)
})
}
}
nojson::Json(Payload { applied }).to_string()
}
fn command_result_json(
stdout: &str,
stderr: &str,
exit_code: Option<i32>,
termination_reason: &str,
elapsed: Duration,
truncated: bool,
) -> String {
struct Payload<'a> {
stdout: &'a str,
stderr: &'a str,
exit_code: Option<i32>,
termination_reason: &'a str,
duration_ms: u64,
truncated: bool,
}
impl DisplayJson for Payload<'_> {
fn fmt(&self, f: &mut nojson::JsonFormatter<'_, '_>) -> std::fmt::Result {
f.object(|f| {
match self.exit_code {
Some(code) => f.member("exit_code", code)?,
None => f.member("exit_code", Option::<i32>::None)?,
}
f.member("termination_reason", self.termination_reason)?;
f.member("duration_ms", self.duration_ms)?;
f.member("truncated", self.truncated)?;
f.member("stdout", self.stdout)?;
f.member("stderr", self.stderr)
})
}
}
let duration_ms = elapsed.as_millis().min(u64::MAX as u128) as u64;
nojson::Json(Payload {
stdout,
stderr,
exit_code,
termination_reason,
duration_ms,
truncated,
})
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn user(ts: u64) -> ChatMessageWithTs {
ChatMessageWithTs {
message: ChatMessage::User(format!("u{ts}")),
ts,
}
}
fn assistant_plain(ts: u64) -> ChatMessageWithTs {
ChatMessageWithTs {
message: ChatMessage::assistant_text(format!("a{ts}")),
ts,
}
}
fn assistant_with_tool_call(ts: u64) -> ChatMessageWithTs {
ChatMessageWithTs {
message: ChatMessage::Assistant {
content: String::new(),
tool_calls: vec![ToolCall {
id: format!("call_{ts}"),
function_name: "read".to_string(),
arguments_json: "{}".to_string(),
}],
},
ts,
}
}
fn tool(ts: u64) -> ChatMessageWithTs {
ChatMessageWithTs {
message: ChatMessage::Tool {
tool_call_id: format!("call_{ts}"),
content: "{}".to_string(),
},
ts,
}
}
#[test]
fn safe_tail_start_returns_zero_when_short_history() {
let records = vec![user(1), assistant_plain(2)];
assert_eq!(safe_tail_start(&records, 10), 0);
}
#[test]
fn safe_tail_start_lands_on_user_record() {
let records = vec![
user(1),
assistant_plain(2),
user(3),
assistant_plain(4),
user(5),
];
assert_eq!(safe_tail_start(&records, 2), 3);
}
#[test]
fn safe_tail_start_advances_past_tool_record() {
let records = vec![
user(1),
assistant_plain(2),
assistant_with_tool_call(3),
tool(4),
user(5),
];
assert_eq!(safe_tail_start(&records, 2), 4);
}
#[test]
fn safe_tail_start_advances_past_assistant_with_tool_calls() {
let records = vec![
user(1),
user(2),
assistant_with_tool_call(3),
tool(4),
user(5),
];
assert_eq!(safe_tail_start(&records, 3), 4);
}
#[test]
fn safe_tail_start_bails_when_no_safe_boundary_in_tail() {
let records = vec![user(1), assistant_with_tool_call(2), tool(3)];
assert_eq!(safe_tail_start(&records, 1), 0);
}
#[test]
fn is_safe_boundary_classifies_records_as_expected() {
assert!(is_safe_boundary(&ChatMessage::User("u".to_string())));
assert!(is_safe_boundary(&ChatMessage::assistant_text("a")));
assert!(!is_safe_boundary(&ChatMessage::Assistant {
content: String::new(),
tool_calls: vec![ToolCall {
id: "x".to_string(),
function_name: "read".to_string(),
arguments_json: "{}".to_string(),
}],
}));
assert!(!is_safe_boundary(&ChatMessage::Tool {
tool_call_id: "x".to_string(),
content: "{}".to_string(),
}));
assert!(!is_safe_boundary(&ChatMessage::System("s".to_string())));
}
fn big_user(ts: u64, len: usize) -> ChatMessageWithTs {
ChatMessageWithTs {
message: ChatMessage::User(format!("u{ts}:{}", "x".repeat(len))),
ts,
}
}
#[test]
fn truncate_prose_keeps_head_and_notes_dropped_chars() {
let s = "abcdefghij".repeat(2000); let out = truncate_prose(&s, 1000);
assert!(out.starts_with("abc"));
assert!(out.contains("[truncated 19000 chars]"));
assert!(out.chars().count() < 2000);
}
#[test]
fn render_summary_transcript_fits_all_within_budget() {
let records = vec![user(1), assistant_plain(2), tool(3)];
let out = render_summary_transcript(&records);
assert!(!out.contains("[Note:"), "unexpected note: {out}");
assert!(out.contains("user: u1"));
assert!(out.contains("assistant: a2"));
assert!(out.contains("[tool result:"));
}
#[test]
fn render_summary_transcript_drops_oldest_when_over_budget() {
let mut records = Vec::new();
for i in 1..=14 {
records.push(big_user(i, SUMMARY_RECORD_MAX_CHARS + 100));
}
let out = render_summary_transcript(&records);
assert!(out.contains("[Note:"), "expected a truncation note");
assert!(out.contains("u14:"), "newest record should be kept");
let kept_oldest_marker = records
.len()
.checked_sub(2)
.map(|_| format!("u{}:", records.len().saturating_sub(2)))
.unwrap_or_default();
assert!(!out.contains("u1:"), "oldest record should be dropped");
assert!(
out.contains(&kept_oldest_marker),
"a kept record ({kept_oldest_marker}) should be present"
);
}
#[test]
fn render_summary_block_truncates_huge_tool_result() {
let huge = "y".repeat(50_000);
let rec = ChatMessageWithTs {
message: ChatMessage::Tool {
tool_call_id: "call_1".to_string(),
content: huge,
},
ts: 1,
};
let out = render_summary_block(&rec);
assert!(
out.contains("[truncated"),
"tool result not truncated: {}",
out.len()
);
assert!(out.chars().count() < 1_000);
}
fn big_tool(ts: u64, len: usize) -> ChatMessageWithTs {
ChatMessageWithTs {
message: ChatMessage::Tool {
tool_call_id: format!("call_{ts}"),
content: "z".repeat(len),
},
ts,
}
}
#[test]
fn compaction_cutoff_returns_none_when_too_short() {
let records = vec![user(1), assistant_plain(2)];
assert_eq!(compaction_cutoff(&records, 10, 1000), None);
}
#[test]
fn compaction_cutoff_returns_safe_tail_start_within_budget() {
let records = vec![
user(1),
assistant_plain(2),
user(3),
assistant_plain(4),
user(5),
];
let keep = safe_tail_start(&records, 2);
assert!(keep > 0);
assert_eq!(compaction_cutoff(&records, 2, 100_000), Some(keep));
}
#[test]
fn compaction_cutoff_folds_few_but_huge_records() {
let records = vec![user(1), assistant_with_tool_call(2), big_tool(3, 300_000)];
assert_eq!(compaction_cutoff(&records, 10, 1000), Some(records.len()));
}
#[test]
fn compaction_cutoff_skips_when_few_and_small() {
let records = vec![user(1), assistant_plain(2)];
assert_eq!(compaction_cutoff(&records, 10, 1000), None);
}
#[test]
fn should_auto_compact_fires_on_token_threshold() {
assert!(should_auto_compact(COMPACTION_TRIGGER_TOKENS, 0));
assert!(should_auto_compact(COMPACTION_TRIGGER_TOKENS + 1, 100));
}
#[test]
fn should_auto_compact_fires_on_record_size_when_tokens_stale() {
assert!(should_auto_compact(0, RECORDS_TOTAL_MAX_CHARS + 1));
assert!(should_auto_compact(1000, RECORDS_TOTAL_MAX_CHARS + 1));
}
#[test]
fn should_auto_compact_stays_quiet_when_everything_is_small() {
assert!(!should_auto_compact(0, RECORDS_TOTAL_MAX_CHARS));
assert!(!should_auto_compact(COMPACTION_TRIGGER_TOKENS - 1, 10));
}
#[test]
fn compaction_cutoff_folds_huge_tail_to_the_end() {
let records = vec![
user(1),
assistant_plain(2),
assistant_with_tool_call(3),
big_tool(4, 50_000),
];
assert_eq!(compaction_cutoff(&records, 2, 100), Some(records.len()));
}
#[test]
fn compaction_cutoff_folds_middle_huge_record_normally() {
let records = vec![
user(1),
assistant_with_tool_call(2),
big_tool(3, 50_000),
user(4),
assistant_plain(5),
];
let keep = safe_tail_start(&records, 2);
assert!(keep > 0 && keep < records.len());
assert_eq!(compaction_cutoff(&records, 2, 100), Some(keep));
}
fn prune_tempdir(name: &str) -> std::path::PathBuf {
let base =
std::env::temp_dir().join(format!("attini-prune-test-{}-{}", name, std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).expect("create tempdir");
base
}
fn write_lines(path: &std::path::Path, lines: &[&str]) {
use std::io::Write as _;
let mut f = std::fs::File::create(path).expect("create");
for l in lines {
f.write_all(l.as_bytes()).expect("write");
f.write_all(b"\n").expect("newline");
}
f.sync_all().expect("sync");
}
#[test]
fn line_is_safe_boundary_classifies_records_as_expected() {
assert!(line_is_safe_boundary(
r#"{"kind":"user","ts":1,"text":"hi"}"#
));
assert!(line_is_safe_boundary(
r#"{"kind":"assistant","ts":2,"text":"done","tool_calls":[]}"#
));
assert!(line_is_safe_boundary(
r#"{"kind":"assistant","ts":3,"text":"","tool_calls":[]}"#
));
assert!(!line_is_safe_boundary(
r#"{"kind":"assistant","ts":4,"text":"","tool_calls":[{"id":"c"}]}"#
));
assert!(!line_is_safe_boundary(
r#"{"kind":"tool","ts":5,"text":"x"}"#
));
assert!(!line_is_safe_boundary(r#"{"kind":"summary","ts":6}"#));
assert!(!line_is_safe_boundary("not json"));
assert!(!line_is_safe_boundary(""));
}
#[test]
fn prune_offset_past_midpoint_lands_on_safe_boundary_after_half() {
let dir = prune_tempdir("midpoint");
let path = dir.join("conv.jsonl");
let pad = "x".repeat(400);
let lines = [
format!(r#"{{"kind":"user","ts":1,"text":"{pad}"}}"#),
format!(r#"{{"kind":"tool","ts":2,"text":"{pad}"}}"#),
format!(r#"{{"kind":"assistant","ts":3,"text":"{pad}","tool_calls":[]}}"#),
format!(r#"{{"kind":"user","ts":4,"text":"{pad}"}}"#),
];
let refs: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
write_lines(&path, &refs);
let size = std::fs::metadata(&path).expect("meta").len();
let (offset, dropped) = prune_offset_past_midpoint(&path, size)
.expect("ok")
.expect("boundary");
assert!(offset >= size / 2);
assert!(dropped >= 1);
let mut cursor: u64 = 0;
let mut expected: Option<(u64, u64)> = None;
for (i, l) in refs.iter().enumerate() {
let start = cursor;
cursor += l.len() as u64 + 1;
if start >= size / 2 && line_is_safe_boundary(l) {
expected = Some((start, i as u64));
break;
}
}
assert_eq!(Some((offset, dropped)), expected);
}
#[test]
fn prune_offset_past_midpoint_returns_none_when_pair_spans_tail() {
let dir = prune_tempdir("no_safe");
let path = dir.join("conv.jsonl");
let pad = "x".repeat(400);
let lines = [
format!(r#"{{"kind":"user","ts":1,"text":"{pad}"}}"#),
r#"{"kind":"assistant","ts":2,"text":"","tool_calls":[{"id":"c"}]}"#.to_string(),
format!(r#"{{"kind":"tool","ts":3,"text":"{pad}"}}"#),
];
let refs: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
write_lines(&path, &refs);
let size = std::fs::metadata(&path).expect("meta").len();
assert!(
prune_offset_past_midpoint(&path, size)
.expect("ok")
.is_none()
);
}
#[test]
fn rewrite_file_from_offset_keeps_suffix_only() {
let dir = prune_tempdir("rewrite");
let path = dir.join("conv.jsonl");
let lines = [
r#"{"kind":"user","ts":1,"text":"first"}"#,
r#"{"kind":"summary","ts":2,"text":"s1"}"#,
r#"{"kind":"user","ts":3,"text":"after"}"#,
];
write_lines(&path, &lines);
let offset: u64 = (lines[0].len() + 1 + lines[1].len() + 1) as u64;
rewrite_file_from_offset(&path, offset).expect("rewrite ok");
let contents = std::fs::read_to_string(&path).expect("read");
let kept: Vec<&str> = contents.trim_end_matches('\n').split('\n').collect();
assert_eq!(kept.len(), 1);
assert!(kept[0].contains("after"));
}
fn gate_config(
turn_limit: usize,
rate: Option<RateLimit>,
session_max: Option<usize>,
) -> TellConfig {
TellConfig {
session_name: String::new(),
model: String::new(),
max_tokens: None,
workspace_root: PathBuf::new(),
system_prompt: None,
max_turns: 0,
turn_tool_call_limit: turn_limit,
tool_call_rate: rate,
session_tool_call_max: session_max,
authorization: Authorization::PerTool,
temperature: None,
grant_request: GrantRequest::None,
command_timeout_seconds: None,
}
}
#[test]
fn gate_turn_limit_admits_up_to_boundary_then_rejects() {
let cfg = gate_config(3, None, None);
let mut gate = ToolCallGate::new(&cfg);
gate.begin_turn();
let now = Instant::now();
assert_eq!(gate.admit(now), GateDecision::Proceed);
assert_eq!(gate.admit(now), GateDecision::Proceed);
assert_eq!(gate.admit(now), GateDecision::Proceed);
assert_eq!(gate.admit(now), GateDecision::TurnLimitExceeded);
}
#[test]
fn gate_turn_limit_resets_after_begin_turn() {
let cfg = gate_config(2, None, None);
let mut gate = ToolCallGate::new(&cfg);
let now = Instant::now();
gate.begin_turn();
assert_eq!(gate.admit(now), GateDecision::Proceed);
assert_eq!(gate.admit(now), GateDecision::Proceed);
assert_eq!(gate.admit(now), GateDecision::TurnLimitExceeded);
gate.begin_turn();
assert_eq!(gate.admit(now), GateDecision::Proceed);
}
#[test]
fn gate_rate_admits_up_to_boundary_within_window_then_rejects() {
let cfg = gate_config(
100,
Some(RateLimit {
calls: 2,
window: Duration::from_secs(10),
}),
None,
);
let mut gate = ToolCallGate::new(&cfg);
gate.begin_turn();
let t0 = Instant::now();
assert_eq!(gate.admit(t0), GateDecision::Proceed);
assert_eq!(gate.admit(t0), GateDecision::Proceed);
assert_eq!(gate.admit(t0), GateDecision::RateLimitExceeded);
}
#[test]
fn gate_rate_window_slides_and_admits_again() {
let cfg = gate_config(
100,
Some(RateLimit {
calls: 2,
window: Duration::from_secs(10),
}),
None,
);
let mut gate = ToolCallGate::new(&cfg);
gate.begin_turn();
let t0 = Instant::now();
assert_eq!(gate.admit(t0), GateDecision::Proceed);
assert_eq!(gate.admit(t0), GateDecision::Proceed);
assert_eq!(gate.admit(t0), GateDecision::RateLimitExceeded);
let t1 = t0 + Duration::from_secs(11);
assert_eq!(gate.admit(t1), GateDecision::Proceed);
}
#[test]
fn gate_rate_rejection_does_not_consume_window_slot() {
let cfg = gate_config(
100,
Some(RateLimit {
calls: 1,
window: Duration::from_secs(10),
}),
None,
);
let mut gate = ToolCallGate::new(&cfg);
gate.begin_turn();
let t0 = Instant::now();
assert_eq!(gate.admit(t0), GateDecision::Proceed);
assert_eq!(gate.admit(t0), GateDecision::RateLimitExceeded);
assert_eq!(gate.admit(t0), GateDecision::RateLimitExceeded);
let t1 = t0 + Duration::from_secs(11);
assert_eq!(gate.admit(t1), GateDecision::Proceed);
assert_eq!(gate.admit(t1), GateDecision::RateLimitExceeded);
}
#[test]
fn gate_session_backstop_admits_up_to_max_then_exhausts() {
let cfg = gate_config(100, None, Some(3));
let mut gate = ToolCallGate::new(&cfg);
let now = Instant::now();
gate.begin_turn();
assert_eq!(gate.admit(now), GateDecision::Proceed);
assert_eq!(gate.admit(now), GateDecision::Proceed);
gate.begin_turn();
assert_eq!(gate.admit(now), GateDecision::Proceed);
assert_eq!(gate.admit(now), GateDecision::SessionExhausted);
}
#[test]
fn gate_none_options_disable_the_check() {
let cfg = gate_config(100, None, None);
let mut gate = ToolCallGate::new(&cfg);
gate.begin_turn();
let now = Instant::now();
for _ in 0..50 {
assert_eq!(gate.admit(now), GateDecision::Proceed);
}
}
#[test]
fn gate_rejection_does_not_consume_session_or_turn_budget() {
let cfg = gate_config(
1,
Some(RateLimit {
calls: 100,
window: Duration::from_secs(10),
}),
Some(3),
);
let mut gate = ToolCallGate::new(&cfg);
let now = Instant::now();
gate.begin_turn();
assert_eq!(gate.admit(now), GateDecision::Proceed);
assert_eq!(gate.admit(now), GateDecision::TurnLimitExceeded);
assert_eq!(gate.admit(now), GateDecision::TurnLimitExceeded);
gate.begin_turn();
assert_eq!(gate.admit(now), GateDecision::Proceed);
gate.begin_turn();
assert_eq!(gate.admit(now), GateDecision::Proceed);
gate.begin_turn();
assert_eq!(gate.admit(now), GateDecision::SessionExhausted);
}
fn call_result(content: &str) -> curl::CallResult {
curl::CallResult {
content: content.to_string(),
tool_calls: Vec::new(),
finish_reason: None,
usage: None,
}
}
#[test]
fn pick_summary_text_returns_content_when_present() {
let r = call_result("hello summary");
assert_eq!(pick_summary_text(&r), Some("hello summary".to_string()));
}
#[test]
fn pick_summary_text_returns_none_when_content_is_blank() {
let r = call_result(" ");
assert_eq!(pick_summary_text(&r), None);
}
#[test]
fn abbreviate_args_strips_newlines_and_truncates() {
let long = "{\"path\":\"src/tell_cli.rs\",\n\"pattern\":\"".repeat(40);
let out = abbreviate_args(&long);
assert!(!out.contains('\n'));
assert!(out.chars().count() <= 91); assert!(out.ends_with('…'));
}
#[test]
fn abbreviate_args_handles_empty() {
assert_eq!(abbreviate_args(""), "");
assert_eq!(abbreviate_args(" "), "");
}
#[test]
fn render_records_prose_strips_raw_tool_format() {
let records = vec![
user(1),
ChatMessageWithTs {
message: ChatMessage::Assistant {
content: "looking at the file".to_string(),
tool_calls: vec![ToolCall {
id: "call_1".to_string(),
function_name: "search".to_string(),
arguments_json: "{\"pattern\":\"foo\"}".to_string(),
}],
},
ts: 2,
},
ChatMessageWithTs {
message: ChatMessage::Tool {
tool_call_id: "call_1".to_string(),
content: "a file
"
.repeat(300),
},
ts: 3,
},
];
let out = render_records_prose(&records);
assert!(out.contains("user: u1"));
assert!(out.contains("assistant: looking at the file"));
assert!(out.contains("[tool call: search ({\"pattern\":\"foo\"})]"));
assert!(out.contains("[tool result: a file"));
assert!(!out.contains("tool_calls"));
assert!(!out.contains("<invoke"));
assert!(!out.contains("function_name"));
assert!(out.len() < 600);
assert!(out.contains('…'));
}
#[test]
fn command_result_json_keeps_full_output_text() {
let stdout = "line 1\nline 2\n".repeat(200);
let stderr = "warning: something\n".repeat(50);
let json = command_result_json(
&stdout,
&stderr,
Some(1),
"exited",
Duration::from_millis(123),
false,
);
assert!(json.contains("\"stdout\":\"line 1\\nline 2\\n"));
assert!(json.contains("\"stderr\":\"warning: something\\n"));
assert!(json.contains("\"exit_code\":1"));
assert!(json.contains("\"termination_reason\":\"exited\""));
assert!(json.contains("\"duration_ms\":123"));
assert!(json.contains("\"truncated\":false"));
}
#[test]
fn command_result_json_roundtrips_no_exit_code() {
let json = command_result_json("out", "", None, "signaled", Duration::ZERO, true);
assert!(json.contains("\"exit_code\":null"));
assert!(json.contains("\"termination_reason\":\"signaled\""));
assert!(json.contains("\"truncated\":true"));
}
#[test]
fn command_result_json_includes_truncated_flag() {
let json = command_result_json("out", "", Some(0), "exited", Duration::ZERO, true);
assert!(json.contains("\"truncated\":true"));
assert!(!json.contains("\"truncated\":false"));
}
#[test]
fn command_timeout_zero_and_none_disable_the_cap() {
let mut cfg = gate_config(1, None, None);
cfg.command_timeout_seconds = None;
assert_eq!(command_timeout(&cfg), None);
cfg.command_timeout_seconds = Some(0);
assert_eq!(command_timeout(&cfg), None);
}
#[test]
fn command_timeout_seconds_becomes_a_duration() {
let mut cfg = gate_config(1, None, None);
cfg.command_timeout_seconds = Some(180);
assert_eq!(command_timeout(&cfg), Some(Duration::from_secs(180)));
}
fn assistant_calls(ids: &[&str]) -> ChatMessage {
ChatMessage::Assistant {
content: String::new(),
tool_calls: ids
.iter()
.map(|id| ToolCall {
id: id.to_string(),
function_name: "command".to_string(),
arguments_json: "{}".to_string(),
})
.collect(),
}
}
fn tool_result(call_id: &str) -> ChatMessage {
ChatMessage::Tool {
tool_call_id: call_id.to_string(),
content: "{}".to_string(),
}
}
#[test]
fn repair_messages_inserts_reject_for_unanswered_sibling() {
let messages = vec![
assistant_calls(&["call_00", "call_01"]),
tool_result("call_00"),
];
let (repaired, orphans) = repair_messages(&messages);
assert_eq!(orphans.len(), 1);
assert_eq!(orphans[0].call_id, "call_01");
assert!(orphans[0].content.contains("unanswered_tool_call"));
assert_eq!(repaired.len(), 3);
assert!(matches!(&repaired[0], ChatMessage::Assistant { .. }));
match &repaired[1] {
ChatMessage::Tool { tool_call_id, .. } => assert_eq!(tool_call_id, "call_00"),
other => panic!("expected tool call_00, got {other:?}"),
}
match &repaired[2] {
ChatMessage::Tool {
tool_call_id,
content,
} => {
assert_eq!(tool_call_id, "call_01");
assert!(content.contains("unanswered_tool_call"));
}
other => panic!("expected synthetic tool call_01, got {other:?}"),
}
}
#[test]
fn repair_messages_leaves_complete_transcript_untouched() {
let messages = vec![
assistant_calls(&["call_00", "call_01"]),
tool_result("call_00"),
tool_result("call_01"),
];
let (repaired, orphans) = repair_messages(&messages);
assert!(orphans.is_empty());
assert_eq!(repaired, messages);
}
#[test]
fn repair_messages_handles_multiple_unanswered_calls() {
let messages = vec![assistant_calls(&["call_00", "call_01", "call_02"])];
let (repaired, orphans) = repair_messages(&messages);
assert_eq!(orphans.len(), 3);
assert_eq!(repaired.len(), 4);
for (idx, id) in ["call_00", "call_01", "call_02"].iter().enumerate() {
match &repaired[idx + 1] {
ChatMessage::Tool { tool_call_id, .. } => assert_eq!(tool_call_id, id),
other => panic!("expected tool {id}, got {other:?}"),
}
}
}
#[test]
fn repair_messages_places_synthetic_before_next_user() {
let messages = vec![
assistant_calls(&["call_00", "call_01"]),
tool_result("call_00"),
ChatMessage::User("continue".to_string()),
];
let (repaired, orphans) = repair_messages(&messages);
assert_eq!(orphans.len(), 1);
assert_eq!(repaired.len(), 4);
assert!(matches!(&repaired[2], ChatMessage::Tool { .. }));
assert!(matches!(&repaired[3], ChatMessage::User(_)));
}
#[test]
fn repair_messages_skips_assistant_without_tool_calls() {
let messages = vec![
ChatMessage::assistant_text("intro"),
assistant_calls(&["call_00"]),
tool_result("call_00"),
];
let (repaired, orphans) = repair_messages(&messages);
assert!(orphans.is_empty());
assert_eq!(repaired, messages);
}
#[test]
fn repair_messages_moves_misplaced_tool_before_user() {
let messages = vec![
assistant_calls(&["call_00", "call_01"]),
tool_result("call_00"),
ChatMessage::User("tudukete".to_string()),
tool_result("call_01"),
];
let (repaired, orphans) = repair_messages(&messages);
assert!(orphans.is_empty());
assert_eq!(repaired.len(), 4);
assert!(matches!(&repaired[0], ChatMessage::Assistant { .. }));
match &repaired[1] {
ChatMessage::Tool { tool_call_id, .. } => assert_eq!(tool_call_id, "call_00"),
other => panic!("expected tool call_00, got {other:?}"),
}
match &repaired[2] {
ChatMessage::Tool { tool_call_id, .. } => assert_eq!(tool_call_id, "call_01"),
other => panic!("expected tool call_01, got {other:?}"),
}
assert!(matches!(&repaired[3], ChatMessage::User(_)));
}
#[test]
fn repair_messages_drops_duplicate_tool_result() {
let messages = vec![
assistant_calls(&["call_00"]),
tool_result("call_00"),
tool_result("call_00"),
];
let (repaired, orphans) = repair_messages(&messages);
assert!(orphans.is_empty());
assert_eq!(repaired.len(), 2);
assert!(matches!(&repaired[0], ChatMessage::Assistant { .. }));
assert!(matches!(&repaired[1], ChatMessage::Tool { .. }));
}
#[test]
fn status_line_render_includes_session_and_ctx() {
let line = render_tell_status_line("deepseek-v4-flash", "main", 20736);
assert_eq!(
line,
"[tell] model=deepseek-v4-flash session=main ctx=20736"
);
}
#[test]
fn status_line_uses_passed_ctx_as_current_size() {
let line = render_tell_status_line("m", "s", 1000);
assert!(line.contains("ctx=1000"));
assert!(line.contains("model=m"));
assert!(line.contains("session=s"));
}
#[test]
fn max_turns_error_points_at_approve_and_tell() {
let msg = max_turns_error(20);
assert!(msg.contains("max_turns=20"));
assert!(msg.contains("\nattini approve # or give a new instruction"));
assert!(!msg.contains("-s "));
}
#[test]
fn resume_prompt_is_non_empty() {
assert!(!RESUME_PROMPT.trim().is_empty());
}
#[test]
fn pending_free_approve_retries_a_transport_error() {
let cont = normalise_pending_free_approve(Some(InvocationEndReason::TransportError));
assert!(matches!(cont, Continuation::Retry));
}
#[test]
fn pending_free_approve_continues_after_other_endings() {
for last in [
Some(InvocationEndReason::Completed),
Some(InvocationEndReason::AwaitingApproval),
Some(InvocationEndReason::Error),
Some(InvocationEndReason::SessionToolCallExhausted),
None,
] {
let cont = normalise_pending_free_approve(last);
match cont {
Continuation::Prompt(text) => assert_eq!(text, RESUME_PROMPT),
Continuation::Retry => panic!("expected Prompt for {last:?}, got Retry"),
Continuation::Approve => {
panic!("expected Prompt for {last:?}, got Approve")
}
}
}
}
#[test]
fn tool_batching_note_forbids_read_only_after_approval_gated_call() {
let note = render_tool_batching_note();
assert!(note.contains("Tool call batching"));
assert!(note.contains("approval"));
assert!(note.contains("last"));
assert!(note.contains("read"));
assert!(note.contains("search"));
assert!(note.contains("do not put"));
}
fn command_pending(call_id: &str, argv: &[&str]) -> Pending {
let argv_json = argv
.iter()
.map(|a| format!("\"{a}\""))
.collect::<Vec<_>>()
.join(",");
Pending {
ts: 0,
call_id: call_id.to_string(),
tool_kind: PendingToolKind::Command,
function_name: "command".to_string(),
arguments_json: format!("{{\"argv\":[{argv_json}]}}"),
preview: String::new(),
}
}
#[test]
fn grant_prefix_truncates_to_two_elements() {
assert_eq!(grant_prefix(&[]), None);
assert_eq!(
grant_prefix(&["cargo".to_string()]),
Some(vec!["cargo".to_string()])
);
assert_eq!(
grant_prefix(&["cargo".to_string(), "test".to_string(), "-q".to_string()]),
Some(vec!["cargo".to_string(), "test".to_string()])
);
}
fn read_pending(call_id: &str, path: &str) -> Pending {
Pending {
ts: 0,
call_id: call_id.to_string(),
tool_kind: PendingToolKind::Read,
function_name: "read".to_string(),
arguments_json: format!("{{\"path\":\"{path}\"}}"),
preview: String::new(),
}
}
#[test]
fn plan_grant_none_and_oneshot_never_persist() {
let root = std::env::temp_dir();
let pendings = vec![command_pending("c1", &["cargo", "test"])];
assert!(
plan_grant(GrantRequest::None, &pendings, &root)
.unwrap()
.is_none()
);
assert!(
plan_grant(GrantRequest::Oneshot, &pendings, &root)
.unwrap()
.is_none()
);
}
#[test]
fn plan_grant_session_resolves_command_prefix() {
let root = std::env::temp_dir();
let pendings = vec![command_pending("c1", &["cargo", "test", "--all"])];
match plan_grant(GrantRequest::Session, &pendings, &root).unwrap() {
Some(GrantIntent::Command(prefix)) => {
assert_eq!(prefix, vec!["cargo".to_string(), "test".to_string()]);
}
other => panic!("expected a command grant, got {other:?}"),
}
}
fn patch_pending(call_id: &str, edits_json: &str) -> Pending {
Pending {
ts: 0,
call_id: call_id.to_string(),
tool_kind: PendingToolKind::Patch,
function_name: "patch".to_string(),
arguments_json: format!("{{\"edits\":[{edits_json}]}}"),
preview: String::new(),
}
}
#[test]
fn plan_grant_resolves_patch_path_relative_to_workspace() {
let dir = std::env::temp_dir().join(format!("attini-patch-grant-{}", std::process::id()));
let sub = dir.join("sub");
let _ = std::fs::create_dir_all(&sub);
let file = sub.join("note.txt");
std::fs::write(&file, "hi").expect("write");
let edit = r#"{"kind":"add","path":"sub/note.txt","content":"new"}"#;
let pendings = vec![patch_pending("p1", edit)];
match plan_grant(GrantRequest::Session, &pendings, &dir).unwrap() {
Some(GrantIntent::Write(path)) => assert_eq!(path, "sub/note.txt"),
other => panic!("expected a write grant, got {other:?}"),
}
let _ = std::fs::remove_file(&file);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn plan_grant_rejects_multi_path_patch() {
let root = std::env::temp_dir();
let edit = concat!(
r#"{"kind":"add","path":"a.txt","content":"x"},"#,
r#"{"kind":"add","path":"b.txt","content":"y"}"#
);
let pendings = vec![patch_pending("p1", edit)];
let err = plan_grant(GrantRequest::Session, &pendings, &root).unwrap_err();
assert!(err.to_string().contains("multiple paths"), "{err}");
}
#[test]
fn plan_grant_rejects_multiple_pending_commands() {
let root = std::env::temp_dir();
let pendings = vec![
command_pending("c1", &["cargo", "test"]),
command_pending("c2", &["git", "status"]),
];
let err = plan_grant(GrantRequest::Workspace, &pendings, &root).unwrap_err();
assert!(err.to_string().contains("ambiguous"), "{err}");
}
#[test]
fn plan_grant_session_resolves_read_path() {
let dir = std::env::temp_dir().join(format!("attini-plan-grant-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let file = dir.join("outside.txt");
std::fs::write(&file, "hi").expect("write");
let root = std::env::temp_dir().join("attini-plan-grant-other");
let pendings = vec![read_pending("r1", &file.display().to_string())];
match plan_grant(GrantRequest::Session, &pendings, &root).unwrap() {
Some(GrantIntent::Read(path)) => {
assert_eq!(path, file.canonicalize().unwrap().display().to_string());
}
other => panic!("expected a read grant, got {other:?}"),
}
let _ = std::fs::remove_file(&file);
let _ = std::fs::remove_dir(&dir);
}
#[test]
fn plan_grant_rejects_unresolvable_read() {
let root = std::env::temp_dir();
let pendings = vec![read_pending("r1", "no-such-file-xyz.txt")];
let err = plan_grant(GrantRequest::Session, &pendings, &root).unwrap_err();
assert!(err.to_string().contains("no resolvable path"), "{err}");
}
fn patch_inv(edits: Vec<PatchTool>) -> PatchInvocation {
PatchInvocation { edits }
}
#[test]
fn patch_diff_shows_before_and_after_lines() {
let inv = patch_inv(vec![PatchTool::Update {
path: "src/a.rs".to_string(),
before: "let x = 1;\nlet y = 2;".to_string(),
after: "let x = 1;\nlet y = 3;".to_string(),
}]);
let out = render_patch_diff(&inv);
assert!(out.contains(" update src/a.rs"), "{out}");
assert!(out.contains(" - let y = 2;"), "{out}");
assert!(out.contains(" + let y = 3;"), "{out}");
}
#[test]
fn patch_diff_shows_add_content() {
let inv = patch_inv(vec![PatchTool::Add {
path: "src/new.rs".to_string(),
content: "fn main() {}".to_string(),
}]);
let out = render_patch_diff(&inv);
assert!(out.contains(" add src/new.rs"), "{out}");
assert!(out.contains(" + fn main() {}"), "{out}");
}
#[test]
fn patch_diff_caps_output_and_notes_omissions() {
let many: String = (0..(PATCH_PREVIEW_MAX_LINES + 50))
.map(|i| format!("line {i}\n"))
.collect();
let inv = patch_inv(vec![PatchTool::Add {
path: "big.txt".to_string(),
content: many,
}]);
let out = render_patch_diff(&inv);
assert!(out.contains("more lines omitted"), "{out}");
assert!(
out.lines().count() <= PATCH_PREVIEW_MAX_LINES + 1,
"printed {} lines",
out.lines().count()
);
}
#[test]
fn patch_preview_text_includes_diff() {
let inv = patch_inv(vec![PatchTool::Update {
path: "src/a.rs".to_string(),
before: "old".to_string(),
after: "new".to_string(),
}]);
let preview = PatchPreview {
target_paths: vec!["src/a.rs".to_string()],
added_lines: 1,
removed_lines: 1,
edit_count: 1,
auto_approve: true,
not_revertible: None,
};
let out = render_patch_preview_text(&preview, &inv);
assert!(out.contains("patch preview: 1 edit(s)"), "{out}");
assert!(out.contains(" - old"), "{out}");
assert!(out.contains(" + new"), "{out}");
}
#[test]
fn patch_approval_footer_restates_summary() {
let preview = PatchPreview {
target_paths: vec!["src/a.rs".to_string(), "src/b.rs".to_string()],
added_lines: 3,
removed_lines: 1,
edit_count: 2,
auto_approve: false,
not_revertible: None,
};
let plain = render_patch_approval_footer(&preview);
assert_eq!(
plain,
"[patch] approval required: 2 edit(s) across 2 file(s), +3 / -1 lines"
);
}
#[test]
fn read_preview_renders_content_lines() {
let payload =
r#"{"content":"line one\nline two","start_line":1,"end_line":2,"truncated":false}"#;
let out = read_content_preview(payload).expect("preview");
assert!(out.contains(" | line one"), "{out}");
assert!(out.contains(" | line two"), "{out}");
}
#[test]
fn read_preview_caps_and_notes_omissions() {
let many: String = (0..(READ_PREVIEW_MAX_LINES + 7))
.map(|i| format!("l{i}"))
.collect::<Vec<_>>()
.join("\\n");
let payload =
format!(r#"{{"content":"{many}","start_line":1,"end_line":1,"truncated":false}}"#);
let out = read_content_preview(&payload).expect("preview");
assert!(out.contains("7 more lines omitted"), "{out}");
assert!(out.lines().count() <= READ_PREVIEW_MAX_LINES + 1, "{out}");
}
#[test]
fn read_preview_is_none_for_non_read_payloads() {
assert!(read_content_preview(r#"{"entries":[],"truncated":false}"#).is_none());
assert!(read_content_preview("not json").is_none());
}
#[test]
fn read_only_target_reads_the_path_field() {
let read = ReadOnlyTool::Read {
path: "../foo.txt".to_string(),
line_range: None,
};
assert_eq!(read_only_target(&read), Some("../foo.txt"));
let search = ReadOnlyTool::Search {
pattern: "x".to_string(),
path_prefix: None,
case_sensitive: false,
max_results: 10,
};
assert_eq!(read_only_target(&search), None);
}
#[test]
fn read_extra_root_is_none_for_missing_path() {
let root = std::env::temp_dir();
let inv = ReadOnlyTool::Read {
path: "definitely-missing-__attini__.txt".to_string(),
line_range: None,
};
assert!(read_extra_root(&inv, &root).is_none());
}
#[test]
fn read_extra_root_canonicalises_existing_path() {
let root = std::env::temp_dir();
let expected = root.canonicalize().unwrap();
let inv = ReadOnlyTool::List {
path: ".".to_string(),
recursive: false,
max_entries: 10,
include_hidden: false,
};
assert_eq!(read_extra_root(&inv, &root), Some(expected));
}
#[test]
fn run_read_only_needs_approval_outside_workspace() {
let workspace =
std::env::temp_dir().join(format!("attini-read-approval-ws-{}", std::process::id()));
std::fs::create_dir_all(&workspace).unwrap();
let executor = ToolExecutor::new(&workspace, Vec::new(), "t".to_string()).unwrap();
let cwd_file = std::env::current_dir().unwrap().join("Cargo.toml");
if !cwd_file.exists() {
let _ = std::fs::remove_dir_all(&workspace);
return;
}
let tc = ToolCall {
id: "call_x".to_string(),
function_name: "read".to_string(),
arguments_json: format!(r#"{{"path":"{}"}}"#, cwd_file.display()),
};
match run_read_only(&tc, &executor) {
ReadOnlyDispatch::NeedsApproval { summary, preview } => {
assert!(summary.contains("approval required"), "{summary}");
assert!(preview.contains("outside workspace"), "{preview}");
}
ReadOnlyDispatch::Done { content, .. } => {
panic!("expected approval request, got: {content}")
}
}
let _ = std::fs::remove_dir_all(&workspace);
}
}