返回 CodeWhale
purge.rs
根目录 / crates / tui / src / purge.rs
1 //! Agent-driven context purging.
2 //!
3 //! Unlike compaction (which summarises old messages via LLM), purge lets the
4 //! agent analyse the conversation history and surgically remove or rewrite
5 //! individual messages that are no longer needed. The agent uses the
6 //! `purge_context` tool to submit a list of operations; the engine validates
7 //! and executes them.
8
9 use regex::Regex;
10 use std::fmt::Write;
11 use tokio::sync::mpsc::Sender;
12
13 use crate::config::ApiProvider;
14 use crate::core::events::Event;
15 use crate::fast_hash::{FastHashMap, FastHashSet};
16 use crate::llm_client::LlmClient;
17 use crate::models::{ContentBlock, Message, MessageRequest, Tool};
18 use crate::regex_cache::compile_user_regex;
19
20 // ── Prompt‑building constants ──────────────────────────────────────────────
21
22 const TEXT_SNIPPET_CHARS: usize = 60;
23 const TOOL_RESULT_SNIPPET_CHARS: usize = 80;
24 const TOOL_USE_ARGS_CHARS: usize = 120;
25
26 // ── Prompt instruction template ─────────────────────────────────────────────
27
28 const PURGE_INSTRUCTIONS: &str = "\
29 ## Context Purge
30
31 Free space in the conversation's context window. Below is the current history with stable numeric IDs.\
32 Identify content that is clearly no longer needed for the ongoing work.
33
34 ### Operations
35
36 remove — Delete an entire message by its ID. Example:
37 {\"op\": \"remove\", \"msg\": 3}
38
39 replace — Rewrite part of a specific content block using regex substitution.
40 pattern uses Rust regex syntax. Must specify both `block` and
41 `pattern` and `with`. Example:
42 {\"op\": \"replace\", \"msg\": 7, \"block\": 0,
43 \"pattern\": \"read \\\\d+ files\", \"with\": \"read files\"}
44
45 ### Pairing rule
46
47 Every ToolUse block is paired with its ToolResult. If you remove a message
48 containing a tool call, its result will be removed too — and vice versa. You
49 do not need to list both.
50
51 ### What to keep
52
53 - Important decisions, architectural choices
54 - File paths that are still relevant
55 - Tool outputs that contain information not yet acted upon
56
57 ### What to prune
58
59 - Verbose tool outputs whose information has been fully consumed
60 - Redundant confirmations (\"done\", \"ok\", \"that worked\")
61 - Superseded file reads (the file was later written/modified)
62 - Boilerplate that the model already incorporated into later work
63
64 Be conservative. When in doubt, keep the message.
65
66 ### Conversation
67 ";
68
69 // ── Purge operation types ───────────────────────────────────────────────────
70
71 /// A single purge operation submitted by the agent.
72 #[derive(Debug, Clone)]
73 pub enum PurgeOp {
74 /// Remove an entire message (plus its tool-call/result counterpart).
75 Remove { msg_id: usize },
76 /// Regex-replace within a specific content block.
77 Replace {
78 msg_id: usize,
79 block_idx: usize,
80 pattern: Regex,
81 with: String,
82 },
83 }
84
85 /// Result of executing purge operations.
86 #[derive(Debug, Clone)]
87 pub struct PurgeResult {
88 /// The remaining messages after all operations.
89 pub messages: Vec<Message>,
90 /// How many messages were removed.
91 pub removed_count: usize,
92 /// How many replace operations were applied.
93 pub replaced_count: usize,
94 }
95
96 // ── Event emission helpers ──────────────────────────────────────────────────
97
98 /// Emit a `PurgeStarted` event to the UI.
99 pub async fn emit_purge_started(tx: &Sender<Event>, message: String) {
100 let _ = tx.send(Event::PurgeStarted { message }).await;
101 }
102
103 /// Emit a `PurgeCompleted` event to the UI.
104 pub async fn emit_purge_completed(
105 tx: &Sender<Event>,
106 messages_before: usize,
107 messages_after: usize,
108 removed_count: usize,
109 replaced_count: usize,
110 message: String,
111 ) {
112 let _ = tx
113 .send(Event::PurgeCompleted {
114 messages_before,
115 messages_after,
116 removed_count,
117 replaced_count,
118 message,
119 })
120 .await;
121 }
122
123 /// Emit a `PurgeFailed` event to the UI.
124 pub async fn emit_purge_failed(tx: &Sender<Event>, message: String) {
125 let _ = tx.send(Event::PurgeFailed { message }).await;
126 }
127
128 // ── Prompt builder ──────────────────────────────────────────────────────────
129
130 /// Build the purge request user message — a formatted listing of the current
131 /// conversation with ephemeral sequential IDs.
132 pub fn build_purge_prompt(messages: &[Message]) -> String {
133 let mut buf = String::with_capacity(messages.len().saturating_mul(256));
134 buf.push_str(PURGE_INSTRUCTIONS);
135
136 for (idx, msg) in messages.iter().enumerate() {
137 let msg_id = idx + 1; // 1‑based for the agent
138 if msg.role == "user" {
139 // User messages: always a single block — omit block index.
140 format_user_message(&mut buf, msg_id, msg);
141 } else {
142 // Assistant messages: may be multi‑block — show block indices.
143 let _ = writeln!(buf, "[{msg_id}] {role}", role = msg.role);
144 for (blk_idx, block) in msg.content.iter().enumerate() {
145 format_content_block(&mut buf, blk_idx, block);
146 }
147 buf.push('\n');
148 }
149 }
150
151 buf
152 }
153
154 fn format_user_message(buf: &mut String, msg_id: usize, msg: &Message) {
155 let block = msg.content.first();
156 match block {
157 Some(ContentBlock::Text { text, .. }) => {
158 let snippet = truncate_str(text, TEXT_SNIPPET_CHARS);
159 let _ = writeln!(
160 buf,
161 "[{msg_id}] user Text ({len} chars): \"{snippet}\"",
162 len = text.len()
163 );
164 }
165 Some(ContentBlock::ToolResult {
166 content,
167 tool_use_id,
168 ..
169 }) => {
170 let snippet = truncate_str(content, TOOL_RESULT_SNIPPET_CHARS);
171 let _ = writeln!(
172 buf,
173 "[{msg_id}] user ToolResult (id={tool_use_id}, {len} chars): \"{snippet}\"",
174 len = content.len(),
175 );
176 }
177 _ => {
178 let _ = writeln!(buf, "[{msg_id}] user (non‑text block)");
179 }
180 }
181 }
182
183 fn format_content_block(buf: &mut String, blk_idx: usize, block: &ContentBlock) {
184 match block {
185 ContentBlock::Text { text, .. } => {
186 let snippet = truncate_str(text, TEXT_SNIPPET_CHARS);
187 let _ = writeln!(
188 buf,
189 " [{blk_idx}] Text ({len} chars): \"{snippet}\"",
190 len = text.len(),
191 );
192 }
193 ContentBlock::Thinking { .. } => {
194 // Omit thinking blocks — API-mandated on tool-call messages;
195 // the agent cannot remove them, so listing them only adds noise.
196 }
197 ContentBlock::ToolUse {
198 name, input, id, ..
199 } => {
200 let args = serde_json::to_string(input).unwrap_or_default();
201 let args_preview = truncate_str(&args, TOOL_USE_ARGS_CHARS);
202 let _ = writeln!(
203 buf,
204 " [{blk_idx}] ToolUse ({name}, id={id}, args={args_preview})"
205 );
206 }
207 ContentBlock::ToolResult {
208 content,
209 tool_use_id,
210 ..
211 } => {
212 let snippet = truncate_str(content, TOOL_RESULT_SNIPPET_CHARS);
213 let _ = writeln!(
214 buf,
215 " [{blk_idx}] ToolResult (id={tool_use_id}, {len} chars): \"{snippet}\"",
216 len = content.len(),
217 );
218 }
219 ContentBlock::ServerToolUse {
220 name, input, id, ..
221 } => {
222 let args = serde_json::to_string(input).unwrap_or_default();
223 let args_preview = truncate_str(&args, TOOL_USE_ARGS_CHARS);
224 let _ = writeln!(
225 buf,
226 " [{blk_idx}] ServerToolUse ({name}, id={id}, args={args_preview})"
227 );
228 }
229 ContentBlock::ToolSearchToolResult {
230 tool_use_id,
231 content,
232 ..
233 } => {
234 let snippet = truncate_str(&content.to_string(), TOOL_RESULT_SNIPPET_CHARS);
235 let _ = writeln!(
236 buf,
237 " [{blk_idx}] ToolSearchToolResult (id={tool_use_id}, content={snippet})"
238 );
239 }
240 ContentBlock::CodeExecutionToolResult {
241 tool_use_id,
242 content,
243 ..
244 } => {
245 let snippet = truncate_str(&content.to_string(), TOOL_RESULT_SNIPPET_CHARS);
246 let _ = writeln!(
247 buf,
248 " [{blk_idx}] CodeExecutionToolResult (id={tool_use_id}, content={snippet})"
249 );
250 }
251 ContentBlock::ImageUrl { .. } => {}
252 }
253 }
254
255 fn truncate_str(text: &str, max_chars: usize) -> String {
256 if text.chars().count() <= max_chars {
257 return text.to_string();
258 }
259 let take = max_chars.saturating_sub(3);
260 let mut out: String = text.chars().take(take).collect();
261 out.push_str("...");
262 out
263 }
264
265 // ── Operation parser ────────────────────────────────────────────────────────
266
267 /// Parse the `purge_context` tool input JSON into a list of validated
268 /// `PurgeOp`s. Returns an error string on invalid input.
269 pub fn parse_purge_operations(
270 input: &serde_json::Value,
271 message_count: usize,
272 ) -> Result<Vec<PurgeOp>, String> {
273 let ops = input
274 .get("operations")
275 .and_then(|v| v.as_array())
276 .ok_or_else(|| "missing or invalid 'operations' array".to_string())?;
277
278 let mut parsed = Vec::with_capacity(ops.len());
279
280 for (i, op) in ops.iter().enumerate() {
281 let op_type = op
282 .get("op")
283 .and_then(|v| v.as_str())
284 .ok_or_else(|| format!("operation[{i}]: missing 'op' field"))?;
285
286 let msg = op
287 .get("msg")
288 .and_then(|v| v.as_u64())
289 .ok_or_else(|| format!("operation[{i}]: missing or invalid 'msg'"))?;
290
291 let msg_id = usize::try_from(msg).unwrap_or(usize::MAX);
292 if msg_id == 0 || msg_id > message_count {
293 return Err(format!(
294 "operation[{i}]: msg {msg} out of range (1–{message_count})"
295 ));
296 }
297
298 match op_type {
299 "remove" => {
300 parsed.push(PurgeOp::Remove { msg_id });
301 }
302 "replace" => {
303 let block_idx = op
304 .get("block")
305 .and_then(|v| v.as_u64())
306 .map(|v| v as usize)
307 .ok_or_else(|| format!("operation[{i}]: 'replace' requires 'block'"))?;
308
309 let pattern_str = op
310 .get("pattern")
311 .and_then(|v| v.as_str())
312 .ok_or_else(|| format!("operation[{i}]: 'replace' requires 'pattern'"))?;
313
314 let with = op
315 .get("with")
316 .and_then(|v| v.as_str())
317 .unwrap_or("")
318 .to_string();
319
320 let pattern = compile_user_regex(pattern_str)
321 .map_err(|e| format!("operation[{i}]: invalid regex pattern: {e}"))?;
322
323 parsed.push(PurgeOp::Replace {
324 msg_id,
325 block_idx,
326 pattern,
327 with,
328 });
329 }
330 other => {
331 return Err(format!(
332 "operation[{i}]: unknown op '{other}' (expected 'remove' or 'replace')"
333 ));
334 }
335 }
336 }
337
338 Ok(parsed)
339 }
340
341 // ── Operation executor ──────────────────────────────────────────────────────
342
343 /// Execute a list of purge operations against the message history.
344 ///
345 /// Operations are processed in the order given but effective removal runs
346 /// from highest index to lowest to keep earlier indices stable. After all
347 /// user-requested operations, tool‑call/result pair cascading runs to
348 /// prevent orphaned blocks.
349 pub fn execute_purge_operations(messages: &[Message], ops: &[PurgeOp]) -> PurgeResult {
350 let mut msgs = messages.to_vec();
351 let mut msg_indices_to_remove: FastHashSet<usize> = FastHashSet::default();
352 let mut replaced_count = 0usize;
353
354 // Phase 1: collect removes and apply replaces.
355 for op in ops {
356 match op {
357 PurgeOp::Remove { msg_id } => {
358 let idx = msg_id.saturating_sub(1);
359 if idx < msgs.len() {
360 msg_indices_to_remove.insert(idx);
361 }
362 }
363 PurgeOp::Replace {
364 msg_id,
365 block_idx,
366 pattern,
367 with,
368 } => {
369 let idx = msg_id.saturating_sub(1);
370 if idx >= msgs.len() {
371 continue;
372 }
373 if let Some(block) = msgs[idx].content.get_mut(*block_idx) {
374 let old_text = block_content_text(block).to_string();
375 let new_text = pattern.replace_all(&old_text, with.as_str()).to_string();
376 apply_block_replacement(block, &new_text);
377 replaced_count = replaced_count.saturating_add(1);
378 }
379 }
380 }
381 }
382
383 // Phase 2: cascade removal to tool-call/result counterparts.
384 cascade_tool_pair_removals(&msgs, &mut msg_indices_to_remove);
385
386 // Phase 3: sort indices descending and remove.
387 let mut to_remove: Vec<usize> = msg_indices_to_remove.into_iter().collect();
388 to_remove.sort_unstable_by(|a, b| b.cmp(a));
389
390 let removed_count = to_remove.len();
391 for idx in to_remove {
392 msgs.remove(idx);
393 }
394
395 PurgeResult {
396 messages: msgs,
397 removed_count,
398 replaced_count,
399 }
400 }
401
402 /// When a message containing a ToolUse or ToolResult is marked for removal,
403 /// cascade that removal to its counterpart so the API never sees orphaned
404 /// blocks. Runs a fixpoint loop until the remove set is closed under pairing.
405 fn cascade_tool_pair_removals(messages: &[Message], remove_set: &mut FastHashSet<usize>) {
406 if remove_set.is_empty() {
407 return;
408 }
409
410 // Internal transcript IDs and message indices are assigned by the engine,
411 // so this per-purge pairing pass can use the faster non-cryptographic hasher.
412 let mut call_id_to_idx: FastHashMap<String, usize> = FastHashMap::default();
413 let mut result_id_to_idx: FastHashMap<String, usize> = FastHashMap::default();
414
415 for (idx, msg) in messages.iter().enumerate() {
416 for block in &msg.content {
417 match block {
418 ContentBlock::ToolUse { id, .. } => {
419 call_id_to_idx.insert(id.clone(), idx);
420 }
421 ContentBlock::ToolResult { tool_use_id, .. } => {
422 result_id_to_idx.insert(tool_use_id.clone(), idx);
423 }
424 _ => {}
425 }
426 }
427 }
428
429 // Fixpoint: when a tool-call is removed, also remove its result (and vice versa).
430 let max_iters = messages.len().max(10);
431 for _ in 0..max_iters {
432 let snapshot: Vec<usize> = remove_set.iter().copied().collect();
433 let mut changed = false;
434
435 for idx in snapshot {
436 let msg = &messages[idx];
437 for block in &msg.content {
438 match block {
439 ContentBlock::ToolUse { id, .. } => {
440 if let Some(&result_idx) = result_id_to_idx.get(id)
441 && remove_set.insert(result_idx)
442 {
443 changed = true;
444 }
445 }
446 ContentBlock::ToolResult { tool_use_id, .. } => {
447 if let Some(&call_idx) = call_id_to_idx.get(tool_use_id)
448 && remove_set.insert(call_idx)
449 {
450 changed = true;
451 }
452 }
453 _ => {}
454 }
455 }
456 }
457
458 if !changed {
459 break;
460 }
461 }
462 }
463
464 fn block_content_text(block: &ContentBlock) -> &str {
465 match block {
466 ContentBlock::Text { text, .. } => text,
467 ContentBlock::ToolResult { content, .. } => content,
468 _ => "",
469 }
470 }
471
472 fn apply_block_replacement(block: &mut ContentBlock, new_text: &str) {
473 match block {
474 ContentBlock::Text { text, .. } => {
475 *text = new_text.to_string();
476 }
477 ContentBlock::ToolResult { content, .. } => {
478 *content = new_text.to_string();
479 }
480 _ => {}
481 }
482 }
483
484 // ── Tool definition builder ──────────────────────────────────────────────────
485
486 /// Build the `purge_context` tool definition sent to the model during a purge
487 /// turn. This tool is ad-hoc — it is not registered in the normal tool catalog
488 /// and has no dispatch handler.
489 pub fn build_purge_tool() -> Tool {
490 Tool {
491 tool_type: None,
492 name: "purge_context".to_string(),
493 description: "Remove or condense conversation history to free context window space."
494 .to_string(),
495 input_schema: serde_json::json!({
496 "type": "object",
497 "properties": {
498 "operations": {
499 "type": "array",
500 "items": {
501 "type": "object",
502 "properties": {
503 "op": {"type": "string", "enum": ["remove", "replace"]},
504 "msg": {"type": "integer"},
505 "block": {"type": "integer"},
506 "pattern": {"type": "string"},
507 "with": {"type": "string"}
508 },
509 "required": ["op", "msg"]
510 }
511 }
512 },
513 "required": ["operations"]
514 }),
515 allowed_callers: None,
516 defer_loading: None,
517 input_examples: None,
518 strict: Some(true),
519 cache_control: None,
520 }
521 }
522
523 // ── Orchestration ────────────────────────────────────────────────────────────
524
525 /// Run a full purge cycle: build the prompt, call the model with the
526 /// `purge_context` tool, parse the response, and execute the operations.
527 ///
528 /// Returns the `PurgeResult` with the modified message list on success,
529 /// or a human-readable error string on failure.
530 ///
531 /// Cost reporting is handled internally as a side-effect of the API call.
532 /// The caller is responsible for emitting start/completed/failed events
533 /// and for replacing the session message list with `PurgeResult.messages`.
534 pub async fn run_purge(
535 client: &impl LlmClient,
536 _provider: ApiProvider,
537 messages: &[Message],
538 model: &str,
539 reasoning_effort: Option<String>,
540 max_tokens: u32,
541 ) -> Result<PurgeResult, String> {
542 // 1. Build the purge prompt from the current conversation.
543 let prompt = build_purge_prompt(messages);
544
545 // 2. Clone messages and inject the prompt as a user message.
546 let mut request_messages = messages.to_vec();
547 request_messages.push(Message {
548 role: "user".to_string(),
549 content: vec![ContentBlock::Text {
550 text: prompt,
551 cache_control: None,
552 }],
553 });
554
555 // 3. Build the tool definition and the request.
556 let purge_tool = build_purge_tool();
557 let request = MessageRequest {
558 model: model.to_string(),
559 messages: request_messages,
560 max_tokens,
561 system: None,
562 tools: Some(vec![purge_tool]),
563 tool_choice: None,
564 metadata: None,
565 thinking: None,
566 reasoning_effort,
567 stream: Some(false),
568 temperature: Some(0.2),
569 top_p: None,
570 };
571
572 // 4. Send to the model. Capture the session scope before awaiting so a
573 // late response cannot accrue into a subsequently loaded/new session.
574 let cost_scope = crate::cost_status::scope_token();
575 let cost_route = client.effective_route_envelope(model, chrono::Utc::now());
576 let response = client
577 .create_message(request)
578 .await
579 .map_err(|e| format!("Purge API error: {e}"))?;
580
581 // Report the route, not just the provider name: the endpoint decides
582 // whether this is a metered public API, a plan quota, or a local runtime.
583 crate::cost_status::report_effective_route(cost_scope, &cost_route, &response.usage);
584
585 // 5. Find the `purge_context` tool call in the response.
586 let tool_input = response.content.iter().find_map(|block| {
587 if let ContentBlock::ToolUse { name, input, .. } = block
588 && name == "purge_context"
589 {
590 return Some(input.clone());
591 }
592 None
593 });
594
595 match tool_input {
596 Some(input) => {
597 let ops = parse_purge_operations(&input, messages.len())
598 .map_err(|e| format!("Purge parse error: {e}"))?;
599 Ok(execute_purge_operations(messages, &ops))
600 }
601 None => Err("Purge: model did not call purge_context tool".to_string()),
602 }
603 }
604
605 // ── Tests ───────────────────────────────────────────────────────────────────
606
607 #[cfg(test)]
608 mod tests {
609 use super::*;
610 use serde_json::json;
611
612 fn msg_text(role: &str, text: &str) -> Message {
613 Message {
614 role: role.to_string(),
615 content: vec![ContentBlock::Text {
616 text: text.to_string(),
617 cache_control: None,
618 }],
619 }
620 }
621
622 fn msg_tool_use(id: &str, name: &str, input: serde_json::Value) -> Message {
623 Message {
624 role: "assistant".to_string(),
625 content: vec![ContentBlock::ToolUse {
626 id: id.to_string(),
627 name: name.to_string(),
628 input,
629 caller: None,
630 }],
631 }
632 }
633
634 fn msg_tool_result(id: &str, content: &str) -> Message {
635 Message {
636 role: "user".to_string(),
637 content: vec![ContentBlock::ToolResult {
638 tool_use_id: id.to_string(),
639 content: content.to_string(),
640 is_error: None,
641 content_blocks: None,
642 }],
643 }
644 }
645
646 #[test]
647 fn parse_remove_operations() {
648 let input = json!({
649 "operations": [
650 {"op": "remove", "msg": 1},
651 {"op": "remove", "msg": 3}
652 ]
653 });
654 let ops = parse_purge_operations(&input, 5).unwrap();
655 assert_eq!(ops.len(), 2);
656 assert!(matches!(ops[0], PurgeOp::Remove { msg_id: 1 }));
657 assert!(matches!(ops[1], PurgeOp::Remove { msg_id: 3 }));
658 }
659
660 #[test]
661 fn parse_replace_operation() {
662 let input = json!({
663 "operations": [
664 {"op": "replace", "msg": 2, "block": 0, "pattern": "hello", "with": "hi"}
665 ]
666 });
667 let ops = parse_purge_operations(&input, 5).unwrap();
668 assert_eq!(ops.len(), 1);
669 assert!(matches!(ops[0], PurgeOp::Replace { msg_id: 2, .. }));
670 }
671
672 #[test]
673 fn parse_rejects_out_of_range_msg() {
674 let input = json!({"operations": [{"op": "remove", "msg": 10}]});
675 assert!(parse_purge_operations(&input, 5).is_err());
676 }
677
678 #[test]
679 fn parse_rejects_invalid_regex() {
680 let input = json!({
681 "operations": [{"op": "replace", "msg": 1, "block": 0, "pattern": "[", "with": "x"}]
682 });
683 assert!(parse_purge_operations(&input, 5).is_err());
684 }
685
686 #[test]
687 fn execute_remove_works() {
688 let msgs = vec![
689 msg_text("user", "hello"),
690 msg_text("assistant", "hi there"),
691 msg_text("user", "bye"),
692 ];
693 let ops = vec![PurgeOp::Remove { msg_id: 2 }];
694 let result = execute_purge_operations(&msgs, &ops);
695 assert_eq!(result.removed_count, 1);
696 assert_eq!(result.messages.len(), 2);
697 }
698
699 #[test]
700 fn execute_replace_text_block() {
701 let msgs = vec![msg_text("assistant", "Hello world! Hello again!")];
702 let pattern = Regex::new("Hello").unwrap();
703 let ops = vec![PurgeOp::Replace {
704 msg_id: 1,
705 block_idx: 0,
706 pattern,
707 with: "Hi".to_string(),
708 }];
709 let result = execute_purge_operations(&msgs, &ops);
710 assert_eq!(result.replaced_count, 1);
711
712 if let ContentBlock::Text { text, .. } = &result.messages[0].content[0] {
713 assert_eq!(text, "Hi world! Hi again!");
714 } else {
715 panic!("expected text block");
716 }
717 }
718
719 #[test]
720 fn tool_call_result_pairing_cascaded() {
721 // Message 2 (idx 1) is a tool call. Message 3 (idx 2) is its result.
722 // Removing the tool call should cascade to remove the result too.
723 let msgs = vec![
724 msg_text("user", "read a file"),
725 msg_tool_use("call_01", "read_file", json!({"path": "x.rs"})),
726 msg_tool_result("call_01", "fn main() {}"),
727 ];
728 let ops = vec![PurgeOp::Remove { msg_id: 2 }]; // remove tool call only
729 let result = execute_purge_operations(&msgs, &ops);
730 // Both tool call and its result should be gone (cascaded).
731 assert_eq!(
732 result.removed_count, 2,
733 "tool call + its result should both be removed"
734 );
735 assert_eq!(result.messages.len(), 1);
736 }
737
738 #[test]
739 fn tool_result_removal_cascades_to_call() {
740 // Removing the result should cascade to remove the call.
741 let msgs = vec![
742 msg_text("user", "read a file"),
743 msg_tool_use("call_01", "read_file", json!({"path": "x.rs"})),
744 msg_tool_result("call_01", "fn main() {}"),
745 ];
746 let ops = vec![PurgeOp::Remove { msg_id: 3 }]; // remove result only
747 let result = execute_purge_operations(&msgs, &ops);
748 assert_eq!(
749 result.removed_count, 2,
750 "tool result + its call should both be removed"
751 );
752 assert_eq!(result.messages.len(), 1);
753 }
754
755 #[test]
756 fn prompt_truncates_long_content() {
757 let long_text = "x".repeat(200);
758 let msgs = vec![msg_text("user", &long_text)];
759 let prompt = build_purge_prompt(&msgs);
760 assert!(prompt.contains("(200 chars)"));
761 assert!(prompt.contains("xxx...")); // truncated
762 assert!(!prompt.contains(&long_text));
763 }
764
765 #[test]
766 fn prompt_shows_full_short_content() {
767 let msgs = vec![msg_text("user", "hi")];
768 let prompt = build_purge_prompt(&msgs);
769 assert!(prompt.contains("\"hi\""));
770 assert!(!prompt.contains("..."));
771 }
772
773 #[test]
774 fn prompt_omits_thinking_blocks() {
775 let msgs = vec![Message {
776 role: "assistant".to_string(),
777 content: vec![
778 ContentBlock::Thinking {
779 signature: None,
780 thinking: "let me think...".to_string(),
781 },
782 ContentBlock::Text {
783 text: "done".to_string(),
784 cache_control: None,
785 },
786 ],
787 }];
788 let prompt = build_purge_prompt(&msgs);
789 assert!(!prompt.contains("let me think"));
790 assert!(prompt.contains("Text (4 chars)"));
791 }
792
793 #[test]
794 fn build_purge_tool_has_correct_shape() {
795 let tool = build_purge_tool();
796 assert_eq!(tool.name, "purge_context");
797 let schema = &tool.input_schema;
798 assert_eq!(schema["type"], "object");
799 assert!(schema["properties"]["operations"]["type"] == "array");
800 let ops_item = &schema["properties"]["operations"]["items"];
801 assert_eq!(ops_item["type"], "object");
802 let required = ops_item["required"].as_array().unwrap();
803 assert!(required.contains(&json!("op")));
804 assert!(required.contains(&json!("msg")));
805 }
806
807 use crate::llm_client::mock::MockLlmClient;
808 use crate::models::{MessageResponse, Usage};
809
810 fn msg_response_with_tool_call(operations: serde_json::Value) -> MessageResponse {
811 MessageResponse {
812 id: "resp_test".to_string(),
813 r#type: "message".to_string(),
814 role: "assistant".to_string(),
815 content: vec![ContentBlock::ToolUse {
816 id: "call_purge".to_string(),
817 name: "purge_context".to_string(),
818 input: json!({"operations": operations}),
819 caller: None,
820 }],
821 model: "mock-model".to_string(),
822 stop_reason: None,
823 stop_sequence: None,
824 container: None,
825 usage: Usage::default(),
826 }
827 }
828
829 fn msg_response_without_tool_call(text: &str) -> MessageResponse {
830 MessageResponse {
831 id: "resp_plain".to_string(),
832 r#type: "message".to_string(),
833 role: "assistant".to_string(),
834 content: vec![ContentBlock::Text {
835 text: text.to_string(),
836 cache_control: None,
837 }],
838 model: "mock".to_string(),
839 stop_reason: None,
840 stop_sequence: None,
841 container: None,
842 usage: Usage::default(),
843 }
844 }
845
846 #[tokio::test]
847 async fn run_purge_removes_message() {
848 let _cost_guard = crate::cost_status::test_scope();
849 let mock = MockLlmClient::new(vec![]);
850 mock.push_message_response(msg_response_with_tool_call(json!([
851 {"op": "remove", "msg": 2}
852 ])));
853
854 let messages = vec![
855 msg_text("user", "hello"),
856 msg_text("assistant", "remove me"),
857 msg_text("user", "bye"),
858 ];
859
860 let result = run_purge(&mock, ApiProvider::Deepseek, &messages, "mock", None, 4096)
861 .await
862 .unwrap();
863 assert_eq!(result.removed_count, 1);
864 assert_eq!(result.replaced_count, 0);
865 assert_eq!(result.messages.len(), 2);
866
867 if let ContentBlock::Text { text, .. } = &result.messages[0].content[0] {
868 assert_eq!(text, "hello");
869 } else {
870 panic!(
871 "expected text block, got {:?}",
872 result.messages[0].content[0]
873 );
874 }
875 if let ContentBlock::Text { text, .. } = &result.messages[1].content[0] {
876 assert_eq!(text, "bye");
877 } else {
878 panic!(
879 "expected text block, got {:?}",
880 result.messages[1].content[0]
881 );
882 }
883 }
884
885 #[tokio::test]
886 async fn run_purge_replace_condenses_text() {
887 let _cost_guard = crate::cost_status::test_scope();
888 let mock = MockLlmClient::new(vec![]);
889 mock.push_message_response(msg_response_with_tool_call(json!([
890 {"op": "replace", "msg": 1, "block": 0, "pattern": "very long and verbose", "with": "short"}
891 ])));
892
893 let messages = vec![msg_text("assistant", "this is very long and verbose text")];
894
895 let result = run_purge(&mock, ApiProvider::Deepseek, &messages, "mock", None, 4096)
896 .await
897 .unwrap();
898 assert_eq!(result.removed_count, 0);
899 assert_eq!(result.replaced_count, 1);
900
901 if let ContentBlock::Text { text, .. } = &result.messages[0].content[0] {
902 assert_eq!(text, "this is short text");
903 } else {
904 panic!(
905 "expected text block, got {:?}",
906 result.messages[0].content[0]
907 );
908 }
909 }
910
911 #[tokio::test]
912 async fn run_purge_errors_when_no_tool_call() {
913 let _cost_guard = crate::cost_status::test_scope();
914 let mock = MockLlmClient::new(vec![]);
915 mock.push_message_response(msg_response_without_tool_call("nothing to clean up"));
916
917 let messages = vec![msg_text("user", "hi")];
918 let err = run_purge(&mock, ApiProvider::Deepseek, &messages, "mock", None, 4096)
919 .await
920 .unwrap_err();
921 assert!(err.contains("did not call purge_context"));
922 }
923
924 #[tokio::test]
925 async fn run_purge_errors_on_api_failure() {
926 let _cost_guard = crate::cost_status::test_scope();
927 // No canned response — MockLlmClient returns an error.
928 let mock = MockLlmClient::new(vec![]);
929 let messages = vec![msg_text("user", "hi")];
930 let err = run_purge(&mock, ApiProvider::Deepseek, &messages, "mock", None, 4096)
931 .await
932 .unwrap_err();
933 assert!(err.contains("Purge API error"));
934 }
935 }
936
936 lines RUST