返回 DeepSeek-TUI-2026
tests.rs
根目录 / crates / tui / src / core / engine / tests.rs
1 use super::*;
2
3 use crate::models::SystemBlock;
4 use serde_json::json;
5 use std::collections::HashSet;
6 use std::ffi::OsString;
7 use std::fs;
8 use std::path::{Path, PathBuf};
9 use std::sync::LazyLock;
10 use std::time::Instant;
11 use tempfile::tempdir;
12
13 const WORKING_SET_SUMMARY_MARKER: &str = "## Repo Working Set";
14 static CAPACITY_MEMORY_ENV_LOCK: LazyLock<tokio::sync::Mutex<()>> =
15 LazyLock::new(|| tokio::sync::Mutex::new(()));
16 static API_KEY_ENV_LOCK: LazyLock<std::sync::Mutex<()>> =
17 LazyLock::new(|| std::sync::Mutex::new(()));
18
19 struct ScopedCapacityMemoryDir {
20 previous: Option<OsString>,
21 }
22
23 impl ScopedCapacityMemoryDir {
24 fn set(path: &Path) -> Self {
25 let previous = std::env::var_os("DEEPSEEK_CAPACITY_MEMORY_DIR");
26 // Safety: capacity-memory tests serialize access with CAPACITY_MEMORY_ENV_LOCK
27 // and restore the original value in Drop.
28 unsafe {
29 std::env::set_var("DEEPSEEK_CAPACITY_MEMORY_DIR", path);
30 }
31 Self { previous }
32 }
33 }
34
35 impl Drop for ScopedCapacityMemoryDir {
36 fn drop(&mut self) {
37 // Safety: capacity-memory tests serialize access with CAPACITY_MEMORY_ENV_LOCK.
38 unsafe {
39 if let Some(previous) = self.previous.take() {
40 std::env::set_var("DEEPSEEK_CAPACITY_MEMORY_DIR", previous);
41 } else {
42 std::env::remove_var("DEEPSEEK_CAPACITY_MEMORY_DIR");
43 }
44 }
45 }
46 }
47
48 struct ScopedDeepSeekApiKey {
49 previous: Option<OsString>,
50 }
51
52 impl ScopedDeepSeekApiKey {
53 fn set(value: &str) -> Self {
54 let previous = std::env::var_os("DEEPSEEK_API_KEY");
55 // Safety: tests using this helper serialize with API_KEY_ENV_LOCK and
56 // restore the original value in Drop.
57 unsafe {
58 std::env::set_var("DEEPSEEK_API_KEY", value);
59 }
60 Self { previous }
61 }
62 }
63
64 impl Drop for ScopedDeepSeekApiKey {
65 fn drop(&mut self) {
66 // Safety: tests using this helper serialize with API_KEY_ENV_LOCK.
67 unsafe {
68 if let Some(previous) = self.previous.take() {
69 std::env::set_var("DEEPSEEK_API_KEY", previous);
70 } else {
71 std::env::remove_var("DEEPSEEK_API_KEY");
72 }
73 }
74 }
75 }
76
77 fn build_engine_with_capacity(capacity: CapacityControllerConfig) -> Engine {
78 let engine_config = EngineConfig {
79 capacity,
80 ..Default::default()
81 };
82 let (engine, _handle) = Engine::new(engine_config, &Config::default());
83 engine
84 }
85
86 #[test]
87 fn env_only_auth_error_gets_recovery_hint() {
88 let _guard = API_KEY_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
89 let _env = ScopedDeepSeekApiKey::set("stale-env-key");
90 let (engine, _handle) = Engine::new(EngineConfig::default(), &Config::default());
91
92 let message =
93 engine.decorate_auth_error_message("Authentication failed: invalid API key".to_string());
94
95 assert!(message.contains("DEEPSEEK_API_KEY"));
96 assert!(message.contains("no saved config key is present"));
97 assert!(message.contains("deepseek auth set --provider deepseek"));
98 }
99
100 #[test]
101 fn config_auth_error_does_not_blame_env() {
102 let _guard = API_KEY_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
103 let _env = ScopedDeepSeekApiKey::set("stale-env-key");
104 let cfg = Config {
105 api_key: Some("fresh-config-key".to_string()),
106 ..Config::default()
107 };
108 let (engine, _handle) = Engine::new(EngineConfig::default(), &cfg);
109
110 let message =
111 engine.decorate_auth_error_message("Authentication failed: invalid API key".to_string());
112
113 assert_eq!(message, "Authentication failed: invalid API key");
114 }
115
116 fn make_plan(
117 read_only: bool,
118 supports_parallel: bool,
119 approval_required: bool,
120 interactive: bool,
121 ) -> ToolExecutionPlan {
122 ToolExecutionPlan {
123 index: 0,
124 id: "tool-1".to_string(),
125 name: "grep_files".to_string(),
126 input: json!({"pattern": "test"}),
127 caller: None,
128 interactive,
129 approval_required,
130 approval_description: "desc".to_string(),
131 supports_parallel,
132 read_only,
133 blocked_error: None,
134 guard_result: None,
135 }
136 }
137
138 fn api_tool(name: &str) -> Tool {
139 Tool {
140 tool_type: Some("function".to_string()),
141 name: name.to_string(),
142 description: format!("Test tool {name}"),
143 input_schema: json!({"type": "object"}),
144 allowed_callers: Some(vec!["direct".to_string()]),
145 defer_loading: None,
146 input_examples: None,
147 strict: None,
148 cache_control: None,
149 }
150 }
151
152 #[test]
153 fn engine_handle_cancel_tracks_latest_turn_token() {
154 let (mut engine, handle) = Engine::new(EngineConfig::default(), &Config::default());
155 let stale_token = engine.cancel_token.clone();
156
157 engine.reset_cancel_token();
158 handle.cancel();
159
160 assert!(engine.cancel_token.is_cancelled());
161 assert!(handle.is_cancelled());
162 assert!(!stale_token.is_cancelled());
163 }
164
165 #[test]
166 fn engine_initial_prompt_includes_configured_goal() {
167 let config = EngineConfig {
168 goal_objective: Some("Fix goal handoff".to_string()),
169 ..Default::default()
170 };
171 let (engine, _handle) = Engine::new(config, &Config::default());
172 let prompt = match engine.session.system_prompt {
173 Some(SystemPrompt::Text(text)) => text,
174 Some(SystemPrompt::Blocks(blocks)) => blocks
175 .into_iter()
176 .map(|block| block.text)
177 .collect::<Vec<_>>()
178 .join("\n"),
179 None => panic!("expected system prompt"),
180 };
181
182 assert!(prompt.contains("<session_goal>"));
183 assert!(prompt.contains("Fix goal handoff"));
184 }
185
186 #[test]
187 fn parallel_batch_requires_read_only_parallel_tools() {
188 let plans = vec![make_plan(true, true, false, false)];
189 assert!(should_parallelize_tool_batch(&plans));
190
191 let plans = vec![
192 make_plan(true, true, false, false),
193 make_plan(true, true, false, false),
194 ];
195 assert!(should_parallelize_tool_batch(&plans));
196
197 let plans = vec![make_plan(false, true, false, false)];
198 assert!(!should_parallelize_tool_batch(&plans));
199
200 let plans = vec![make_plan(true, false, false, false)];
201 assert!(!should_parallelize_tool_batch(&plans));
202
203 let plans = vec![make_plan(true, true, true, false)];
204 assert!(!should_parallelize_tool_batch(&plans));
205
206 let plans = vec![make_plan(true, true, false, true)];
207 assert!(!should_parallelize_tool_batch(&plans));
208 }
209
210 #[test]
211 fn successful_update_plan_ends_plan_mode_turn_immediately() {
212 assert!(should_stop_after_plan_tool(
213 AppMode::Plan,
214 "update_plan",
215 &Ok(ToolResult::success("planned"))
216 ));
217 assert!(!should_stop_after_plan_tool(
218 AppMode::Agent,
219 "update_plan",
220 &Ok(ToolResult::success("planned"))
221 ));
222 assert!(!should_stop_after_plan_tool(
223 AppMode::Plan,
224 "request_user_input",
225 &Ok(ToolResult::success("input"))
226 ));
227 assert!(!should_stop_after_plan_tool(
228 AppMode::Plan,
229 "update_plan",
230 &Err(ToolError::execution_failed("failed".to_string()))
231 ));
232 }
233
234 #[test]
235 fn quick_plan_requests_force_update_plan_on_first_step() {
236 assert!(should_force_update_plan_first(
237 AppMode::Plan,
238 "Give me a quick 3-step plan to verify the UI changes."
239 ));
240 assert!(should_force_update_plan_first(
241 AppMode::Plan,
242 "Make a high-level plan for the footer work."
243 ));
244 assert!(!should_force_update_plan_first(
245 AppMode::Plan,
246 "Inspect the repo and then give me a quick plan."
247 ));
248 assert!(!should_force_update_plan_first(
249 AppMode::Agent,
250 "Give me a quick 3-step plan."
251 ));
252 }
253
254 #[test]
255 fn quick_plan_turn_can_narrow_first_step_tools_to_update_plan() {
256 let catalog = vec![
257 Tool {
258 tool_type: Some("function".to_string()),
259 name: "read_file".to_string(),
260 description: "Read a file".to_string(),
261 input_schema: json!({"type": "object"}),
262 allowed_callers: Some(vec!["direct".to_string()]),
263 defer_loading: Some(false),
264 input_examples: None,
265 strict: None,
266 cache_control: None,
267 },
268 Tool {
269 tool_type: Some("function".to_string()),
270 name: "update_plan".to_string(),
271 description: "Publish a plan".to_string(),
272 input_schema: json!({"type": "object"}),
273 allowed_callers: Some(vec!["direct".to_string()]),
274 defer_loading: Some(false),
275 input_examples: None,
276 strict: None,
277 cache_control: None,
278 },
279 ];
280 let active = initial_active_tools(&catalog);
281
282 let forced = active_tools_for_step(&catalog, &active, true);
283 assert_eq!(forced.len(), 1);
284 assert_eq!(forced[0].name, "update_plan");
285
286 let default = active_tools_for_step(&catalog, &active, false);
287 assert_eq!(default.len(), 2);
288 }
289
290 #[test]
291 fn tool_error_messages_include_actionable_hints() {
292 let path_error = ToolError::path_escape(PathBuf::from("../escape.txt"));
293 let formatted = format_tool_error(&path_error, "read_file");
294 assert!(formatted.contains("escapes workspace"));
295
296 let missing_field = ToolError::missing_field("path");
297 let formatted = format_tool_error(&missing_field, "read_file");
298 assert!(formatted.contains("missing required field"));
299
300 let timeout = ToolError::Timeout { seconds: 5 };
301 let formatted = format_tool_error(&timeout, "exec_shell");
302 assert!(formatted.contains("timed out"));
303 }
304
305 #[test]
306 fn tool_exec_outcome_tracks_duration() {
307 let outcome = ToolExecOutcome {
308 index: 0,
309 id: "tool-1".to_string(),
310 name: "grep_files".to_string(),
311 input: json!({"pattern": "test"}),
312 started_at: Instant::now(),
313 result: Ok(ToolResult::success("ok")),
314 };
315
316 assert!(outcome.started_at.elapsed().as_nanos() > 0);
317 }
318
319 #[test]
320 fn yolo_mode_keeps_tools_preloaded() {
321 assert!(!should_default_defer_tool("exec_shell", AppMode::Yolo));
322 assert!(!should_default_defer_tool(
323 "mcp_read_resource",
324 AppMode::Yolo
325 ));
326 }
327
328 #[test]
329 fn non_yolo_mode_retains_default_defer_policy() {
330 // Shell tools are kept loaded in action modes so the model can verify
331 // work without an extra ToolSearch round-trip; non-action tools (e.g.
332 // MCP) still defer.
333 assert!(!should_default_defer_tool("exec_shell", AppMode::Agent));
334 assert!(should_default_defer_tool("exec_shell", AppMode::Plan));
335 assert!(!should_default_defer_tool("read_file", AppMode::Agent));
336 assert!(should_default_defer_tool(
337 "mcp_read_resource",
338 AppMode::Agent
339 ));
340 }
341
342 #[test]
343 fn model_tool_catalog_applies_native_and_mcp_deferral() {
344 let catalog = build_model_tool_catalog(
345 vec![
346 api_tool("read_file"),
347 api_tool("exec_shell"),
348 api_tool("project_map"),
349 ],
350 vec![api_tool("list_mcp_resources"), api_tool("mcp_server_write")],
351 AppMode::Agent,
352 );
353
354 let defer_loading = |name: &str| {
355 catalog
356 .iter()
357 .find(|tool| tool.name == name)
358 .and_then(|tool| tool.defer_loading)
359 };
360
361 assert_eq!(defer_loading("read_file"), Some(false));
362 assert_eq!(defer_loading("exec_shell"), Some(false));
363 assert_eq!(defer_loading("project_map"), Some(true));
364 assert_eq!(defer_loading("list_mcp_resources"), Some(false));
365 assert_eq!(defer_loading("mcp_server_write"), Some(true));
366 }
367
368 #[test]
369 fn model_tool_catalog_keeps_everything_loaded_in_yolo_mode() {
370 let catalog = build_model_tool_catalog(
371 vec![api_tool("project_map")],
372 vec![api_tool("mcp_server_write")],
373 AppMode::Yolo,
374 );
375
376 assert!(catalog.iter().all(|tool| tool.defer_loading == Some(false)));
377 }
378
379 #[test]
380 fn model_tool_catalog_sorts_each_partition_for_prefix_cache_stability() {
381 // Regression for #263: deterministic byte order of the tools array is a
382 // hard requirement for DeepSeek's KV prefix cache. Built-ins stay as a
383 // contiguous prefix; MCP tools follow. Within each partition: alphabetical.
384 let catalog = build_model_tool_catalog(
385 vec![
386 api_tool("read_file"),
387 api_tool("apply_patch"),
388 api_tool("exec_shell"),
389 ],
390 vec![api_tool("mcp_zoo_b"), api_tool("mcp_aardvark_a")],
391 AppMode::Yolo,
392 );
393
394 let names: Vec<&str> = catalog.iter().map(|t| t.name.as_str()).collect();
395 assert_eq!(
396 names,
397 vec![
398 "apply_patch",
399 "exec_shell",
400 "read_file",
401 "mcp_aardvark_a",
402 "mcp_zoo_b",
403 ],
404 "built-ins must be alphabetical and contiguous; MCP tools follow, alphabetical",
405 );
406 }
407
408 #[test]
409 fn active_tool_list_pushes_deferred_activations_to_the_tail() {
410 // Regression for #263: when ToolSearch activates a deferred tool mid-
411 // session, it must NOT be inserted at its catalog index — that would
412 // shift every later tool's byte offset and bust the cached prefix.
413 // Deferred-but-now-active tools belong at the tail.
414 let mut a = api_tool("a_load_now");
415 a.defer_loading = Some(false);
416 let mut search = api_tool("search_via_toolsearch");
417 search.defer_loading = Some(true);
418 let mut b = api_tool("b_load_now");
419 b.defer_loading = Some(false);
420
421 let catalog = vec![a, search, b];
422 let active: HashSet<String> = ["a_load_now", "search_via_toolsearch", "b_load_now"]
423 .into_iter()
424 .map(String::from)
425 .collect();
426
427 let listed = active_tools_for_step(&catalog, &active, false);
428 let names: Vec<&str> = listed.iter().map(|t| t.name.as_str()).collect();
429 assert_eq!(
430 names,
431 vec!["a_load_now", "b_load_now", "search_via_toolsearch"],
432 "deferred-but-active tools must come after always-loaded tools",
433 );
434 }
435
436 #[test]
437 fn turn_tool_registry_builder_keeps_plan_mode_read_only_for_files() {
438 let (engine, _handle) = Engine::new(EngineConfig::default(), &Config::default());
439 let registry = engine
440 .build_turn_tool_registry_builder(
441 AppMode::Plan,
442 engine.config.todos.clone(),
443 engine.config.plan_state.clone(),
444 )
445 .build(engine.build_tool_context(AppMode::Plan, false));
446
447 assert!(registry.contains("read_file"));
448 assert!(registry.contains("list_dir"));
449 assert!(!registry.contains("write_file"));
450 assert!(!registry.contains("edit_file"));
451 assert!(registry.contains("update_plan"));
452 assert!(registry.contains("task_create"));
453 }
454
455 #[test]
456 fn agent_mode_can_build_auto_approved_tool_context() {
457 let (engine, _handle) = Engine::new(EngineConfig::default(), &Config::default());
458
459 assert!(
460 !engine
461 .build_tool_context(AppMode::Agent, false)
462 .auto_approve
463 );
464 assert!(engine.build_tool_context(AppMode::Agent, true).auto_approve);
465 assert!(engine.build_tool_context(AppMode::Yolo, false).auto_approve);
466 }
467
468 #[test]
469 fn agent_and_yolo_modes_elevate_shell_sandbox_to_allow_network() {
470 // Regression for #273: the seatbelt-default policy denies all outbound
471 // network (including DNS), which broke `curl`, `yt-dlp`, package managers,
472 // and similar shell commands in Agent mode. Elevation must include
473 // network access so the application-level NetworkPolicy stays the only
474 // outbound boundary.
475 let (engine, _handle) = Engine::new(EngineConfig::default(), &Config::default());
476
477 let agent_ctx = engine.build_tool_context(AppMode::Agent, false);
478 let agent_policy = agent_ctx
479 .elevated_sandbox_policy
480 .as_ref()
481 .expect("Agent mode should elevate the sandbox policy");
482 assert!(
483 agent_policy.has_network_access(),
484 "Agent mode must allow shell network access; got {agent_policy:?}",
485 );
486
487 let yolo_ctx = engine.build_tool_context(AppMode::Yolo, false);
488 let yolo_policy = yolo_ctx
489 .elevated_sandbox_policy
490 .as_ref()
491 .expect("Yolo mode should elevate the sandbox policy");
492 assert!(yolo_policy.has_network_access());
493 // v0.8.11: YOLO drops to DangerFullAccess (no sandbox) so the user
494 // is not bounced through approval round-trips for legitimate
495 // outside-workspace writes (package installs, sub-agent
496 // workspaces, ~/.cache mutations, etc.). YOLO is opt-in and
497 // already enables trust mode + auto-approve; the sandbox was the
498 // last guardrail and contradicts the contract.
499 assert!(
500 matches!(yolo_policy, crate::sandbox::SandboxPolicy::DangerFullAccess),
501 "Yolo mode must use DangerFullAccess (no sandbox); got {yolo_policy:?}",
502 );
503
504 // Plan mode is read-only investigation and does not register the shell
505 // tool, so it intentionally leaves the policy at the strict default.
506 assert!(
507 engine
508 .build_tool_context(AppMode::Plan, false)
509 .elevated_sandbox_policy
510 .is_none(),
511 );
512 }
513
514 #[tokio::test]
515 async fn session_update_preserves_reasoning_tool_only_turn() {
516 let (mut engine, handle) = Engine::new(EngineConfig::default(), &Config::default());
517 let assistant = Message {
518 role: "assistant".to_string(),
519 content: vec![
520 ContentBlock::Thinking {
521 thinking: "Need a tool before answering.".to_string(),
522 },
523 ContentBlock::ToolUse {
524 id: "tool-1".to_string(),
525 name: "read_file".to_string(),
526 input: json!({"path": "Cargo.toml"}),
527 caller: None,
528 },
529 ],
530 };
531
532 engine.add_session_message(assistant.clone()).await;
533
534 let event = {
535 let mut rx = handle.rx_event.write().await;
536 rx.recv().await.expect("session update event")
537 };
538 let Event::SessionUpdated { messages, .. } = event else {
539 panic!("expected session update event");
540 };
541
542 assert_eq!(messages, vec![assistant]);
543 }
544
545 #[test]
546 fn detects_context_length_errors_from_provider_payloads() {
547 let msg = r#"SSE stream request failed: HTTP 400 Bad Request: {"error":{"message":"This model's maximum context length is 131072 tokens. However, you requested 153056 tokens (148960 in the messages, 4096 in the completion).","type":"invalid_request_error"}}"#;
548 assert!(is_context_length_error_message(msg));
549 assert!(!is_context_length_error_message(
550 "SSE stream request failed: HTTP 400 Bad Request: model not found"
551 ));
552 }
553
554 #[test]
555 fn context_budget_reserves_output_and_headroom() {
556 // V4 has a 1M context window — the only family that comfortably hosts
557 // a 256K output reservation without saturating the input budget to 0.
558 let budget = context_input_budget("deepseek-v4-pro", TURN_MAX_OUTPUT_TOKENS)
559 .expect("deepseek-v4-pro should have a known context window");
560 let v4_window: usize = 1_000_000;
561 let expected = v4_window - (TURN_MAX_OUTPUT_TOKENS as usize) - 1_024usize;
562 assert_eq!(budget, expected);
563 }
564
565 #[test]
566 fn effective_max_output_tokens_caps_api_request_for_large_window_models() {
567 // V4 models have a 1M context window but the API request cap must stay
568 // well below common provider limits (e.g., 131K total on self-hosted
569 // vLLM/SGLang). The cap should never exceed 65K.
570 let v4_cap = effective_max_output_tokens("deepseek-v4-pro");
571 assert!(
572 v4_cap <= 65_536,
573 "V4 API request cap should be ≤64K, got {v4_cap}"
574 );
575 assert!(
576 v4_cap > 0,
577 "V4 API request cap should be positive, got {v4_cap}"
578 );
579
580 let flash_cap = effective_max_output_tokens("deepseek-v4-flash");
581 assert_eq!(v4_cap, flash_cap);
582 }
583
584 #[test]
585 fn internal_context_budget_unaffected_by_api_request_cap() {
586 // The internal context budget (used for compaction/preflight/recovery)
587 // must still use the full TURN_MAX_OUTPUT_TOKENS headroom, NOT the
588 // smaller API request cap. This ensures long-context V4 sessions don't
589 // compact prematurely.
590 let internal_budget = context_input_budget("deepseek-v4-pro", TURN_MAX_OUTPUT_TOKENS)
591 .expect("V4 should have a known context window");
592 let api_cap_budget = context_input_budget(
593 "deepseek-v4-pro",
594 effective_max_output_tokens("deepseek-v4-pro"),
595 )
596 .expect("V4 should have a known context window");
597
598 // Internal budget reserves 262K for output; API-cap budget would only
599 // reserve 64K. Internal budget must be smaller (more conservative).
600 assert!(
601 internal_budget < api_cap_budget,
602 "Internal budget ({internal_budget}) should be smaller than API-cap budget ({api_cap_budget}) \
603 because it reserves more headroom for output"
604 );
605
606 // Verify the internal budget is what the compaction logic actually uses.
607 let v4_window: usize = 1_000_000;
608 let expected_internal = v4_window - (TURN_MAX_OUTPUT_TOKENS as usize) - 1_024usize;
609 assert_eq!(internal_budget, expected_internal);
610 }
611
612 #[test]
613 fn v4_tool_outputs_keep_large_file_reads_in_context() {
614 let content = "0123456789abcdef\n".repeat(2_000);
615 let output = ToolResult::success(content.clone());
616
617 let v4_context = compact_tool_result_for_context("deepseek-v4-pro", "exec_shell", &output);
618 assert_eq!(v4_context, content.trim());
619
620 let legacy_context =
621 compact_tool_result_for_context("deepseek-v3.2-128k", "exec_shell", &output);
622 assert!(legacy_context.contains("output compacted to protect context"));
623 assert!(legacy_context.len() < v4_context.len());
624 }
625
626 #[test]
627 fn subagent_results_are_summarized_before_parent_context_insertion() {
628 let long_result = "verified detail\n".repeat(1_000);
629 let output = ToolResult::success(
630 json!({
631 "agent_id": "agent_1234abcd",
632 "agent_type": "explore",
633 "assignment": {
634 "objective": "Inspect the RLM rendering path and report the smallest fix."
635 },
636 "model": "deepseek-v4-flash",
637 "status": "Completed",
638 "result": long_result,
639 "steps_taken": 12,
640 "duration_ms": 3456
641 })
642 .to_string(),
643 );
644
645 let context = compact_tool_result_for_context("deepseek-v4-pro", "agent_result", &output);
646
647 assert!(context.contains("[sub-agent result summarized for parent context]"));
648 assert!(context.contains("agent_1234abcd (explore) status=Completed"));
649 assert!(context.contains("Inspect the RLM rendering path"));
650 assert!(context.contains("steps=12"));
651 assert!(context.len() < output.content.len());
652 }
653
654 #[test]
655 fn refresh_system_prompt_leaves_working_set_out_of_system_prompt() {
656 let tmp = tempdir().expect("tempdir");
657 fs::create_dir_all(tmp.path().join("src")).expect("mkdir");
658 fs::write(tmp.path().join("src/lib.rs"), "pub fn sample() {}").expect("write");
659
660 let config = EngineConfig {
661 workspace: tmp.path().to_path_buf(),
662 ..Default::default()
663 };
664 let (mut engine, _handle) = Engine::new(config, &Config::default());
665 engine
666 .session
667 .working_set
668 .observe_user_message("please inspect src/lib.rs", tmp.path());
669
670 engine.refresh_system_prompt(AppMode::Agent);
671
672 let prompt = match &engine.session.system_prompt {
673 Some(SystemPrompt::Text(text)) => text.clone(),
674 Some(SystemPrompt::Blocks(blocks)) => blocks
675 .iter()
676 .map(|block| block.text.as_str())
677 .collect::<Vec<_>>()
678 .join("\n"),
679 None => panic!("expected system prompt"),
680 };
681 assert!(!prompt.contains(WORKING_SET_SUMMARY_MARKER));
682 }
683
684 #[test]
685 fn working_set_reaches_model_as_turn_metadata() {
686 let tmp = tempdir().expect("tempdir");
687 fs::create_dir_all(tmp.path().join("src")).expect("mkdir");
688 fs::write(tmp.path().join("src/lib.rs"), "pub fn sample() {}").expect("write");
689
690 let config = EngineConfig {
691 workspace: tmp.path().to_path_buf(),
692 ..Default::default()
693 };
694 let (mut engine, _handle) = Engine::new(config, &Config::default());
695 engine
696 .session
697 .working_set
698 .observe_user_message("please inspect src/lib.rs", tmp.path());
699 engine.session.add_message(Message {
700 role: "user".to_string(),
701 content: vec![ContentBlock::Text {
702 text: "please inspect src/lib.rs".to_string(),
703 cache_control: None,
704 }],
705 });
706
707 let messages = engine.messages_with_turn_metadata();
708 let first_block = messages
709 .last()
710 .and_then(|message| message.content.first())
711 .expect("turn metadata block");
712 let ContentBlock::Text { text, .. } = first_block else {
713 panic!("expected text metadata block");
714 };
715 assert!(text.starts_with("<turn_meta>\n"));
716 assert!(text.contains(WORKING_SET_SUMMARY_MARKER));
717 assert!(text.contains("src/lib.rs"));
718 }
719
720 #[test]
721 fn turn_metadata_includes_current_local_date_without_working_set() {
722 let tmp = tempdir().expect("tempdir");
723 let config = EngineConfig {
724 workspace: tmp.path().to_path_buf(),
725 ..Default::default()
726 };
727 let (mut engine, _handle) = Engine::new(config, &Config::default());
728 engine.session.add_message(Message {
729 role: "user".to_string(),
730 content: vec![ContentBlock::Text {
731 text: "what is today's date?".to_string(),
732 cache_control: None,
733 }],
734 });
735
736 let messages = engine.messages_with_turn_metadata();
737 let first_block = messages
738 .last()
739 .and_then(|message| message.content.first())
740 .expect("turn metadata block");
741 let ContentBlock::Text { text, .. } = first_block else {
742 panic!("expected text metadata block");
743 };
744
745 let today = chrono::Local::now().format("%Y-%m-%d").to_string();
746 assert!(text.starts_with("<turn_meta>\n"));
747 assert!(text.contains(&format!("Current local date: {today}")));
748 }
749
750 /// v0.8.11 regression: tool-result messages serialize to role="tool" on
751 /// the wire but are stored as role="user" internally. Prepending
752 /// `<turn_meta>` text onto a tool-result message broke the
753 /// assistant→tool_result invariant and caused HTTP 400 from DeepSeek's
754 /// API ("insufficient tool messages following tool_calls"). The fix:
755 /// inject only into messages that have a Text content block and no
756 /// ToolResult blocks; mid-turn (tool-result is the trailing user
757 /// message) the injection skips.
758 #[test]
759 fn turn_metadata_skips_tool_result_messages() {
760 let tmp = tempdir().expect("tempdir");
761 fs::create_dir_all(tmp.path().join("src")).expect("mkdir");
762 fs::write(tmp.path().join("src/lib.rs"), "pub fn sample() {}").expect("write");
763
764 let config = EngineConfig {
765 workspace: tmp.path().to_path_buf(),
766 ..Default::default()
767 };
768 let (mut engine, _handle) = Engine::new(config, &Config::default());
769 engine
770 .session
771 .working_set
772 .observe_user_message("inspect src/lib.rs", tmp.path());
773
774 // Real user message — should be eligible for injection.
775 engine.session.add_message(Message {
776 role: "user".to_string(),
777 content: vec![ContentBlock::Text {
778 text: "inspect src/lib.rs".to_string(),
779 cache_control: None,
780 }],
781 });
782 // Assistant tool-call.
783 engine.session.add_message(Message {
784 role: "assistant".to_string(),
785 content: vec![ContentBlock::ToolUse {
786 id: "call_42".to_string(),
787 name: "read_file".to_string(),
788 input: serde_json::json!({"path": "src/lib.rs"}),
789 caller: None,
790 }],
791 });
792 // Tool result, stored as role="user" internally.
793 engine.session.add_message(Message {
794 role: "user".to_string(),
795 content: vec![ContentBlock::ToolResult {
796 tool_use_id: "call_42".to_string(),
797 content: "pub fn sample() {}".to_string(),
798 is_error: None,
799 content_blocks: None,
800 }],
801 });
802
803 let messages = engine.messages_with_turn_metadata();
804
805 // The trailing message is the tool result and MUST be untouched —
806 // no Text block sneaking in front of the ToolResult block.
807 let trailing = messages.last().expect("trailing message");
808 assert_eq!(trailing.role, "user");
809 assert_eq!(trailing.content.len(), 1);
810 assert!(matches!(
811 trailing.content.first(),
812 Some(ContentBlock::ToolResult { .. })
813 ));
814
815 // The earlier real user message receives the turn_meta prefix.
816 let real_user = messages.first().expect("first user message");
817 assert_eq!(real_user.role, "user");
818 let ContentBlock::Text { text, .. } = real_user.content.first().expect("user text content")
819 else {
820 panic!("expected Text block on real user message");
821 };
822 assert!(text.starts_with("<turn_meta>\n"));
823 assert!(text.contains("src/lib.rs"));
824 }
825
826 /// When the turn is mid-execution and the trailing user message is a
827 /// tool result, no turn_meta is injected at all (rather than landing on
828 /// some earlier user message and confusing the API's tool-call
829 /// continuity check). The working_set surfaces again on the next
830 /// genuine user prompt.
831 #[test]
832 fn turn_metadata_skips_when_only_tool_results_trail() {
833 let tmp = tempdir().expect("tempdir");
834 fs::create_dir_all(tmp.path().join("src")).expect("mkdir");
835 fs::write(tmp.path().join("src/lib.rs"), "pub fn sample() {}").expect("write");
836
837 let config = EngineConfig {
838 workspace: tmp.path().to_path_buf(),
839 ..Default::default()
840 };
841 let (mut engine, _handle) = Engine::new(config, &Config::default());
842 engine
843 .session
844 .working_set
845 .observe_user_message("inspect src/lib.rs", tmp.path());
846
847 // Only a tool-result message in history — simulates the corner case
848 // where the prior real user message has already been compacted away
849 // but a tool-result is still pending. We must not retroactively
850 // inject.
851 engine.session.add_message(Message {
852 role: "user".to_string(),
853 content: vec![ContentBlock::ToolResult {
854 tool_use_id: "call_42".to_string(),
855 content: "pub fn sample() {}".to_string(),
856 is_error: None,
857 content_blocks: None,
858 }],
859 });
860
861 let messages = engine.messages_with_turn_metadata();
862
863 // Returned unchanged: the single tool-result message, no Text
864 // prefix, content length == 1.
865 let only = messages.last().expect("trailing message");
866 assert_eq!(only.content.len(), 1);
867 assert!(matches!(
868 only.content.first(),
869 Some(ContentBlock::ToolResult { .. })
870 ));
871 }
872
873 #[test]
874 fn refresh_system_prompt_is_noop_when_unchanged() {
875 let tmp = tempdir().expect("tempdir");
876 let config = EngineConfig {
877 workspace: tmp.path().to_path_buf(),
878 ..Default::default()
879 };
880 let (mut engine, _handle) = Engine::new(config, &Config::default());
881
882 engine.refresh_system_prompt(AppMode::Agent);
883 let first_hash = engine.session.last_system_prompt_hash;
884 let first_prompt = engine.session.system_prompt.clone();
885 engine.refresh_system_prompt(AppMode::Agent);
886
887 assert_eq!(engine.session.last_system_prompt_hash, first_hash);
888 assert_eq!(engine.session.system_prompt, first_prompt);
889 }
890
891 #[test]
892 fn compaction_summary_stays_in_stable_system_prompt() {
893 let tmp = tempdir().expect("tempdir");
894 fs::create_dir_all(tmp.path().join("src")).expect("mkdir");
895 fs::write(tmp.path().join("src/main.rs"), "fn main() {}").expect("write");
896
897 let config = EngineConfig {
898 workspace: tmp.path().to_path_buf(),
899 ..Default::default()
900 };
901 let (mut engine, _handle) = Engine::new(config, &Config::default());
902 engine
903 .session
904 .working_set
905 .observe_user_message("continue in src/main.rs", tmp.path());
906 engine.refresh_system_prompt(AppMode::Agent);
907 engine.merge_compaction_summary(Some(SystemPrompt::Blocks(vec![SystemBlock {
908 block_type: "text".to_string(),
909 text: format!("{COMPACTION_SUMMARY_MARKER}\nsummary"),
910 cache_control: None,
911 }])));
912
913 let prompt = match &engine.session.system_prompt {
914 Some(SystemPrompt::Text(text)) => text.clone(),
915 Some(SystemPrompt::Blocks(blocks)) => blocks
916 .iter()
917 .map(|block| block.text.as_str())
918 .collect::<Vec<_>>()
919 .join("\n"),
920 None => panic!("expected system prompt"),
921 };
922
923 assert!(prompt.contains(COMPACTION_SUMMARY_MARKER));
924 assert!(!prompt.contains(WORKING_SET_SUMMARY_MARKER));
925 }
926
927 #[tokio::test]
928 async fn pre_request_refresh_skips_compaction_below_normal_threshold() {
929 let capacity = CapacityControllerConfig {
930 enabled: true,
931 low_risk_max: 0.0,
932 medium_risk_max: 1.0,
933 min_turns_before_guardrail: 0,
934 ..Default::default()
935 };
936
937 let mut engine = build_engine_with_capacity(capacity.clone());
938 engine.config.capacity = capacity.clone();
939 engine.capacity_controller = CapacityController::new(capacity);
940 engine.turn_counter = 5;
941 engine
942 .capacity_controller
943 .mark_turn_start(engine.turn_counter);
944 engine.session.model = "deepseek-v4-pro".to_string();
945 engine.config.model = "deepseek-v4-pro".to_string();
946
947 for i in 0..20 {
948 engine.session.messages.push(Message {
949 role: "user".to_string(),
950 content: vec![ContentBlock::Text {
951 text: format!("small message {i}"),
952 cache_control: None,
953 }],
954 });
955 }
956
957 let before = engine.estimated_input_tokens();
958 let before_len = engine.session.messages.len();
959 let turn = TurnContext::new(10);
960 let applied = engine
961 .run_capacity_pre_request_checkpoint(&turn, None, AppMode::Agent)
962 .await;
963 let after = engine.estimated_input_tokens();
964
965 assert!(!applied);
966 assert_eq!(after, before);
967 assert_eq!(engine.session.messages.len(), before_len);
968 }
969
970 #[tokio::test]
971 async fn pre_request_refresh_invoked_when_medium_risk() {
972 let capacity = CapacityControllerConfig {
973 enabled: true,
974 low_risk_max: 0.0,
975 medium_risk_max: 1.0,
976 min_turns_before_guardrail: 0,
977 ..Default::default()
978 };
979
980 let mut engine = build_engine_with_capacity(capacity.clone());
981 engine.config.capacity = capacity.clone();
982 engine.capacity_controller = CapacityController::new(capacity);
983 engine.turn_counter = 5;
984 engine
985 .capacity_controller
986 .mark_turn_start(engine.turn_counter);
987
988 // Pin the model to an explicit 128k-context variant so the pressure ratio stays
989 // stable regardless of changes to the workspace-wide default model.
990 engine.session.model = "deepseek-v3.2-128k".to_string();
991 engine.config.model = "deepseek-v3.2-128k".to_string();
992
993 let long = "x".repeat(5_000);
994 for _ in 0..900 {
995 engine.session.messages.push(Message {
996 role: "user".to_string(),
997 content: vec![ContentBlock::Text {
998 text: long.clone(),
999 cache_control: None,
1000 }],
1001 });
1002 }
1003
1004 let before = engine.estimated_input_tokens();
1005 let turn = TurnContext::new(10);
1006 let applied = engine
1007 .run_capacity_pre_request_checkpoint(&turn, None, AppMode::Agent)
1008 .await;
1009 let after = engine.estimated_input_tokens();
1010
1011 assert!(applied);
1012 assert!(after < before);
1013 }
1014
1015 #[tokio::test]
1016 async fn post_tool_replay_invoked_when_high_non_severe_risk() {
1017 let tmp = tempdir().expect("tempdir");
1018 fs::write(tmp.path().join("sample.txt"), "hello replay").expect("write");
1019
1020 let capacity = CapacityControllerConfig {
1021 enabled: true,
1022 low_risk_max: 0.0,
1023 medium_risk_max: 0.0,
1024 severe_min_slack: -10.0,
1025 severe_violation_ratio: 2.0,
1026 min_turns_before_guardrail: 0,
1027 ..Default::default()
1028 };
1029
1030 let mut engine = build_engine_with_capacity(capacity.clone());
1031 engine.session.workspace = tmp.path().to_path_buf();
1032 engine.config.workspace = tmp.path().to_path_buf();
1033 engine.config.capacity = capacity.clone();
1034 engine.capacity_controller = CapacityController::new(capacity);
1035 engine.turn_counter = 4;
1036 engine
1037 .capacity_controller
1038 .mark_turn_start(engine.turn_counter);
1039
1040 let mut turn = TurnContext::new(10);
1041 let mut tool_call = TurnToolCall::new(
1042 "tool_read_1".to_string(),
1043 "read_file".to_string(),
1044 json!({ "path": "sample.txt" }),
1045 );
1046 tool_call.set_result(
1047 "hello replay".to_string(),
1048 std::time::Duration::from_millis(1),
1049 );
1050 turn.record_tool_call(tool_call);
1051
1052 let registry = ToolRegistryBuilder::new()
1053 .with_read_only_file_tools()
1054 .build(engine.build_tool_context(AppMode::Agent, false));
1055
1056 let restarted = engine
1057 .run_capacity_post_tool_checkpoint(
1058 &turn,
1059 AppMode::Agent,
1060 Some(&registry),
1061 Arc::new(RwLock::new(())),
1062 None,
1063 0,
1064 0,
1065 )
1066 .await;
1067
1068 assert!(!restarted);
1069 let has_verification_note = engine.session.messages.iter().any(|msg| {
1070 msg.content.iter().any(|block| match block {
1071 ContentBlock::ToolResult { content, .. } => content.contains("[verification replay]"),
1072 _ => false,
1073 })
1074 });
1075 assert!(has_verification_note);
1076 }
1077
1078 #[tokio::test]
1079 async fn error_escalation_triggers_replan_when_severe_or_repeated_failures() {
1080 let _env_lock = CAPACITY_MEMORY_ENV_LOCK.lock().await;
1081 let tmp = tempdir().expect("tempdir");
1082 let _env = ScopedCapacityMemoryDir::set(tmp.path());
1083
1084 let capacity = CapacityControllerConfig {
1085 enabled: true,
1086 low_risk_max: 0.0,
1087 medium_risk_max: 0.0,
1088 min_turns_before_guardrail: 0,
1089 ..Default::default()
1090 };
1091
1092 let mut engine = build_engine_with_capacity(capacity.clone());
1093 engine.config.capacity = capacity.clone();
1094 engine.capacity_controller = CapacityController::new(capacity);
1095 engine.turn_counter = 6;
1096 engine
1097 .capacity_controller
1098 .mark_turn_start(engine.turn_counter);
1099
1100 for i in 0..10 {
1101 engine.session.messages.push(Message {
1102 role: if i % 2 == 0 { "user" } else { "assistant" }.to_string(),
1103 content: vec![ContentBlock::Text {
1104 text: format!("noise message {i}"),
1105 cache_control: None,
1106 }],
1107 });
1108 }
1109 engine.session.messages.push(Message {
1110 role: "user".to_string(),
1111 content: vec![ContentBlock::Text {
1112 text: "Please finish task".to_string(),
1113 cache_control: None,
1114 }],
1115 });
1116
1117 let before_len = engine.session.messages.len();
1118 let turn = TurnContext::new(10);
1119 let restarted = engine
1120 .run_capacity_error_escalation_checkpoint(&turn, AppMode::Agent, 2, 2, &[])
1121 .await;
1122
1123 assert!(restarted);
1124 assert!(engine.session.messages.len() < before_len);
1125 assert!(engine.session.messages.len() <= 2);
1126
1127 let records = load_last_k_capacity_records(&engine.session.id, 1).expect("load memory");
1128 assert!(!records.is_empty());
1129 assert!(!records[0].canonical_state.goal.is_empty());
1130 }
1131
1132 /// v0.8.11: `CapacityControllerConfig::default()` ships with
1133 /// `enabled = false`. The capacity controller's destructive
1134 /// interventions (TargetedContextRefresh silently runs compaction;
1135 /// VerifyAndReplan clears the session message log) silently rewrote
1136 /// or nuked the user's transcript ("resetting plan" footer +
1137 /// black-screen symptom). v0.8.11 commits to "trust the model with
1138 /// the full 1M-token context, only compact on explicit user
1139 /// /compact" — auto-managing the prefix contradicts that posture.
1140 /// Power users can still opt in via `capacity.enabled = true`.
1141 #[tokio::test]
1142 async fn capacity_disabled_by_default_keeps_messages_intact() {
1143 let _env_lock = CAPACITY_MEMORY_ENV_LOCK.lock().await;
1144 let tmp = tempdir().expect("tempdir");
1145 let _env = ScopedCapacityMemoryDir::set(tmp.path());
1146
1147 // Default config — what real users get.
1148 let mut engine = build_engine_with_capacity(CapacityControllerConfig::default());
1149 assert!(
1150 !engine.config.capacity.enabled,
1151 "capacity controller must be off by default in v0.8.11+"
1152 );
1153 engine.turn_counter = 6;
1154 engine
1155 .capacity_controller
1156 .mark_turn_start(engine.turn_counter);
1157
1158 for i in 0..10 {
1159 engine.session.messages.push(Message {
1160 role: if i % 2 == 0 { "user" } else { "assistant" }.to_string(),
1161 content: vec![ContentBlock::Text {
1162 text: format!("noise message {i}"),
1163 cache_control: None,
1164 }],
1165 });
1166 }
1167 engine.session.messages.push(Message {
1168 role: "user".to_string(),
1169 content: vec![ContentBlock::Text {
1170 text: "Please finish task".to_string(),
1171 cache_control: None,
1172 }],
1173 });
1174
1175 let before_len = engine.session.messages.len();
1176 let turn = TurnContext::new(10);
1177 let restarted = engine
1178 .run_capacity_error_escalation_checkpoint(&turn, AppMode::Agent, 2, 2, &[])
1179 .await;
1180
1181 // Capacity is disabled → no replan, no message clear.
1182 assert!(!restarted);
1183 assert_eq!(engine.session.messages.len(), before_len);
1184 }
1185
1186 #[tokio::test]
1187 async fn controller_disabled_keeps_behavior_unchanged() {
1188 let capacity = CapacityControllerConfig {
1189 enabled: false,
1190 ..Default::default()
1191 };
1192
1193 let mut engine = build_engine_with_capacity(capacity.clone());
1194 engine.config.capacity = capacity.clone();
1195 engine.capacity_controller = CapacityController::new(capacity);
1196 engine.turn_counter = 3;
1197 engine
1198 .capacity_controller
1199 .mark_turn_start(engine.turn_counter);
1200
1201 let long = "y".repeat(5_000);
1202 for _ in 0..120 {
1203 engine.session.messages.push(Message {
1204 role: "user".to_string(),
1205 content: vec![ContentBlock::Text {
1206 text: long.clone(),
1207 cache_control: None,
1208 }],
1209 });
1210 }
1211
1212 let before = engine.estimated_input_tokens();
1213 let before_len = engine.session.messages.len();
1214 let turn = TurnContext::new(10);
1215 let applied = engine
1216 .run_capacity_pre_request_checkpoint(&turn, None, AppMode::Agent)
1217 .await;
1218 let after = engine.estimated_input_tokens();
1219 let after_len = engine.session.messages.len();
1220
1221 assert!(!applied);
1222 assert_eq!(before, after);
1223 assert_eq!(before_len, after_len);
1224 }
1225
1226 #[test]
1227 fn caller_policy_defaults_to_direct() {
1228 let tool = Tool {
1229 tool_type: None,
1230 name: "read_file".to_string(),
1231 description: "Read".to_string(),
1232 input_schema: json!({"type":"object"}),
1233 allowed_callers: Some(vec!["direct".to_string()]),
1234 defer_loading: Some(false),
1235 input_examples: None,
1236 strict: None,
1237 cache_control: None,
1238 };
1239 let direct = ToolCaller {
1240 caller_type: "direct".to_string(),
1241 tool_id: None,
1242 };
1243 let code = ToolCaller {
1244 caller_type: "code_execution_20250825".to_string(),
1245 tool_id: Some("srvtoolu_1".to_string()),
1246 };
1247 assert!(caller_allowed_for_tool(Some(&direct), Some(&tool)));
1248 assert!(!caller_allowed_for_tool(Some(&code), Some(&tool)));
1249 assert!(caller_allowed_for_tool(None, Some(&tool)));
1250 }
1251
1252 #[test]
1253 fn tool_search_activates_discovered_deferred_tools() {
1254 let mut catalog = vec![
1255 Tool {
1256 tool_type: None,
1257 name: "read_file".to_string(),
1258 description: "Read files".to_string(),
1259 input_schema: json!({"type":"object","properties":{"path":{"type":"string"}}}),
1260 allowed_callers: Some(vec!["direct".to_string()]),
1261 defer_loading: Some(true),
1262 input_examples: None,
1263 strict: None,
1264 cache_control: None,
1265 },
1266 Tool {
1267 tool_type: None,
1268 name: "grep_files".to_string(),
1269 description: "Search files".to_string(),
1270 input_schema: json!({"type":"object","properties":{"pattern":{"type":"string"}}}),
1271 allowed_callers: Some(vec!["direct".to_string()]),
1272 defer_loading: Some(true),
1273 input_examples: None,
1274 strict: None,
1275 cache_control: None,
1276 },
1277 ];
1278 ensure_advanced_tooling(&mut catalog);
1279 let mut active = initial_active_tools(&catalog);
1280 let result = execute_tool_search(
1281 TOOL_SEARCH_BM25_NAME,
1282 &json!({"query":"read file"}),
1283 &catalog,
1284 &mut active,
1285 )
1286 .expect("search succeeds");
1287 assert!(result.success);
1288 assert!(active.contains("read_file"));
1289 }
1290
1291 #[tokio::test]
1292 async fn code_execution_runs_python_and_returns_result_payload() {
1293 let tmp = tempdir().expect("tempdir");
1294 let result =
1295 execute_code_execution_tool(&json!({"code":"print('hello from code exec')"}), tmp.path())
1296 .await
1297 .expect("code execution should run");
1298 assert!(result.content.contains("hello from code exec"));
1299 assert!(result.content.contains("return_code"));
1300 }
1301
1302 #[test]
1303 fn deferred_tool_requests_are_auto_activated() {
1304 use std::collections::HashSet;
1305
1306 let catalog = vec![Tool {
1307 tool_type: None,
1308 name: "exec_shell".to_string(),
1309 description: "Run shell commands".to_string(),
1310 input_schema: json!({"type":"object","properties":{"cmd":{"type":"string"}}}),
1311 allowed_callers: Some(vec!["direct".to_string()]),
1312 defer_loading: Some(true),
1313 input_examples: None,
1314 strict: None,
1315 cache_control: None,
1316 }];
1317
1318 let mut active = HashSet::new();
1319 assert!(!active.contains("exec_shell"));
1320 assert!(maybe_activate_requested_deferred_tool(
1321 "exec_shell",
1322 &catalog,
1323 &mut active
1324 ));
1325 assert!(active.contains("exec_shell"));
1326 }
1327
1328 #[test]
1329 fn missing_tool_error_message_offers_suggestions() {
1330 let catalog = vec![
1331 Tool {
1332 tool_type: None,
1333 name: "read_file".to_string(),
1334 description: "Read file contents".to_string(),
1335 input_schema: json!({"type":"object","properties":{"path":{"type":"string"}}}),
1336 allowed_callers: Some(vec!["direct".to_string()]),
1337 defer_loading: Some(false),
1338 input_examples: None,
1339 strict: None,
1340 cache_control: None,
1341 },
1342 Tool {
1343 tool_type: None,
1344 name: "grep_files".to_string(),
1345 description: "Search file contents".to_string(),
1346 input_schema: json!({"type":"object","properties":{"pattern":{"type":"string"}}}),
1347 allowed_callers: Some(vec!["direct".to_string()]),
1348 defer_loading: Some(false),
1349 input_examples: None,
1350 strict: None,
1351 cache_control: None,
1352 },
1353 ];
1354
1355 let message = missing_tool_error_message("reed_file", &catalog);
1356 assert!(message.contains("Did you mean:"));
1357 assert!(message.contains("read_file"));
1358 assert!(message.contains(TOOL_SEARCH_BM25_NAME));
1359 }
1360
1361 #[test]
1362 fn missing_tool_error_message_includes_discovery_guidance_when_no_match() {
1363 let catalog = vec![Tool {
1364 tool_type: None,
1365 name: "read_file".to_string(),
1366 description: "Read file contents".to_string(),
1367 input_schema: json!({"type":"object","properties":{"path":{"type":"string"}}}),
1368 allowed_callers: Some(vec!["direct".to_string()]),
1369 defer_loading: Some(false),
1370 input_examples: None,
1371 strict: None,
1372 cache_control: None,
1373 }];
1374
1375 let message = missing_tool_error_message("totally_unknown_tool", &catalog);
1376 assert!(message.contains("not available in the current tool catalog"));
1377 assert!(message.contains(TOOL_SEARCH_BM25_NAME));
1378 }
1379
1380 #[test]
1381 fn filter_tool_call_delta_strips_bracket_marker() {
1382 let mut in_block = false;
1383 let visible = filter_tool_call_delta(
1384 "intro [TOOL_CALL]\n{\"tool\":\"x\"}\n[/TOOL_CALL] outro",
1385 &mut in_block,
1386 );
1387 assert!(!in_block);
1388 assert!(!visible.contains("[TOOL_CALL]"));
1389 assert!(!visible.contains("[/TOOL_CALL]"));
1390 assert!(!visible.contains("\"tool\":\"x\""));
1391 assert!(visible.contains("intro"));
1392 assert!(visible.contains("outro"));
1393 }
1394
1395 #[test]
1396 fn filter_tool_call_delta_strips_deepseek_xml_marker() {
1397 let mut in_block = false;
1398 let visible = filter_tool_call_delta(
1399 "before <deepseek:tool_call name=\"x\">payload</deepseek:tool_call> after",
1400 &mut in_block,
1401 );
1402 assert!(!in_block);
1403 for marker in TOOL_CALL_START_MARKERS {
1404 assert!(
1405 !visible.contains(marker),
1406 "visible text leaked start marker `{marker}`: {visible:?}"
1407 );
1408 }
1409 assert!(visible.contains("before"));
1410 assert!(visible.contains("after"));
1411 }
1412
1413 #[test]
1414 fn filter_tool_call_delta_strips_generic_tool_call_marker() {
1415 let mut in_block = false;
1416 let visible = filter_tool_call_delta(
1417 "lead <tool_call>\n{\"name\":\"do\"}\n</tool_call> tail",
1418 &mut in_block,
1419 );
1420 assert!(!in_block);
1421 assert!(!visible.contains("<tool_call"));
1422 assert!(!visible.contains("</tool_call>"));
1423 assert!(visible.contains("lead"));
1424 assert!(visible.contains("tail"));
1425 }
1426
1427 #[test]
1428 fn filter_tool_call_delta_strips_invoke_marker() {
1429 let mut in_block = false;
1430 let visible = filter_tool_call_delta(
1431 "alpha <invoke name=\"x\"><parameter name=\"k\">v</parameter></invoke> beta",
1432 &mut in_block,
1433 );
1434 assert!(!in_block);
1435 assert!(!visible.contains("<invoke "));
1436 assert!(!visible.contains("</invoke>"));
1437 assert!(visible.contains("alpha"));
1438 assert!(visible.contains("beta"));
1439 }
1440
1441 #[test]
1442 fn filter_tool_call_delta_strips_function_calls_marker() {
1443 let mut in_block = false;
1444 let visible = filter_tool_call_delta(
1445 "head <function_calls>\n{\"name\":\"x\"}\n</function_calls> tail",
1446 &mut in_block,
1447 );
1448 assert!(!in_block);
1449 assert!(!visible.contains("<function_calls>"));
1450 assert!(!visible.contains("</function_calls>"));
1451 assert!(visible.contains("head"));
1452 assert!(visible.contains("tail"));
1453 }
1454
1455 #[test]
1456 fn filter_tool_call_delta_handles_chunk_split_marker() {
1457 let mut in_block = false;
1458 // First chunk opens the wrapper but does not close it.
1459 let visible_a = filter_tool_call_delta("hello <tool_call>partial", &mut in_block);
1460 assert!(in_block, "filter must remember it is mid-wrapper");
1461 assert_eq!(visible_a, "hello ");
1462
1463 // Second chunk continues inside the wrapper, then closes it and adds tail.
1464 let visible_b = filter_tool_call_delta("payload</tool_call> tail", &mut in_block);
1465 assert!(!in_block);
1466 assert_eq!(visible_b, " tail");
1467 }
1468
1469 #[test]
1470 fn filter_tool_call_delta_unmatched_open_suppresses_remainder() {
1471 let mut in_block = false;
1472 let visible = filter_tool_call_delta("ok [TOOL_CALL]rest of stream", &mut in_block);
1473 assert_eq!(visible, "ok ");
1474 assert!(
1475 in_block,
1476 "unmatched open must leave filter in tool-call mode"
1477 );
1478 }
1479
1480 #[test]
1481 fn filter_tool_call_delta_passes_through_clean_text() {
1482 let mut in_block = false;
1483 let input = "no markers here, just prose with code `<not a tag>`.";
1484 let visible = filter_tool_call_delta(input, &mut in_block);
1485 assert!(!in_block);
1486 assert_eq!(visible, input);
1487 }
1488
1489 #[test]
1490 fn contains_fake_tool_wrapper_detects_each_marker() {
1491 for marker in TOOL_CALL_START_MARKERS {
1492 let needle = format!("noise {marker} more noise");
1493 assert!(
1494 contains_fake_tool_wrapper(&needle),
1495 "marker `{marker}` should be detected"
1496 );
1497 }
1498 }
1499
1500 #[test]
1501 fn contains_fake_tool_wrapper_returns_false_on_clean_text() {
1502 assert!(!contains_fake_tool_wrapper(
1503 "plain assistant text without wrappers"
1504 ));
1505 assert!(!contains_fake_tool_wrapper(
1506 "`<tool` lookalike but not a real start marker"
1507 ));
1508 }
1509
1510 #[test]
1511 fn fake_wrapper_notice_is_compact_and_actionable() {
1512 // Keep this short so it fits cleanly in a single status line.
1513 assert!(FAKE_WRAPPER_NOTICE.len() < 120);
1514 assert!(FAKE_WRAPPER_NOTICE.contains("API tool channel"));
1515 }
1516
1517 // ---- final_tool_input: bug-class regression for "<command>" placeholder ----
1518 //
1519 // Background: a streamed tool block carries its `input` in two pieces — an
1520 // initial value at `ContentBlockStart` (often `{}`), then `InputJsonDelta`
1521 // chunks that build up `input_buffer`. The TUI used to fire `ToolCallStarted`
1522 // from `ContentBlockStart` with the empty initial input and never re-emit
1523 // once args were known, so cells rendered the literal text `<command>` /
1524 // `<file>` placeholders. The fix relocates the emission to `ContentBlockStop`
1525 // and routes the input through `final_tool_input`, which prefers the parsed
1526 // buffer over a stale empty placeholder.
1527 fn tool_state(initial: serde_json::Value, buffer: &str) -> ToolUseState {
1528 ToolUseState {
1529 id: "t1".into(),
1530 name: "exec_shell".into(),
1531 input: initial,
1532 caller: None,
1533 input_buffer: buffer.into(),
1534 }
1535 }
1536
1537 #[test]
1538 fn final_tool_input_prefers_parsed_buffer_over_empty_initial() {
1539 // The exact regression: ContentBlockStart delivered `{}`, then args
1540 // streamed in via InputJsonDelta. The emitted ToolCallStarted must
1541 // carry the parsed buffer, not the placeholder.
1542 let state = tool_state(json!({}), r#"{"command": "ls -la"}"#);
1543 assert_eq!(final_tool_input(&state), json!({"command": "ls -la"}));
1544 }
1545
1546 #[test]
1547 fn final_tool_input_falls_back_to_initial_when_buffer_empty() {
1548 // Models occasionally embed args directly in the start frame and never
1549 // send any InputJsonDelta. We must still report those args.
1550 let state = tool_state(json!({"command": "echo hi"}), "");
1551 assert_eq!(final_tool_input(&state), json!({"command": "echo hi"}));
1552 }
1553
1554 #[test]
1555 fn final_tool_input_repairs_unparseable_buffer() {
1556 // The arg_repair module converts unparseable input to an empty object
1557 // {} so dispatch always proceeds. The buffer wins over the initial input.
1558 let state = tool_state(json!({"command": "echo hi"}), "{not json");
1559 assert_eq!(final_tool_input(&state), json!({}));
1560 }
1561
1562 // === #103 transparent stream-retry policy =====================================
1563
1564 #[test]
1565 fn stream_retry_zero_content_then_error_is_transparently_retried() {
1566 // Case 2 from issue #103: stream yielded ZERO content then errored.
1567 // The decoder hit Err on the very first poll → engine should retry
1568 // because DeepSeek hasn't billed and the user has seen nothing.
1569 assert!(
1570 super::should_transparently_retry_stream(false, 0, false),
1571 "first attempt with no content must be eligible for transparent retry"
1572 );
1573 assert!(
1574 super::should_transparently_retry_stream(false, 1, false),
1575 "second attempt (one prior retry) with no content must still be eligible"
1576 );
1577 }
1578
1579 #[test]
1580 fn stream_retry_after_content_received_surfaces_error() {
1581 // Case 3 from issue #103: stream yielded content then errored. We must
1582 // NOT transparently retry — the model has emitted billed output tokens
1583 // and the UI has streamed deltas; resending would double-bill and the
1584 // user would see the same prefix twice.
1585 assert!(
1586 !super::should_transparently_retry_stream(true, 0, false),
1587 "any content received → no transparent retry, even with full budget"
1588 );
1589 assert!(
1590 !super::should_transparently_retry_stream(true, 1, false),
1591 "any content received → no transparent retry on subsequent attempts"
1592 );
1593 }
1594
1595 #[test]
1596 fn stream_retry_budget_caps_transparent_retries_at_two() {
1597 // Case 4 from issue #103: after MAX_TRANSPARENT_STREAM_RETRIES attempts
1598 // we stop trying transparently and let the outer error path surface.
1599 // (The outer per-turn `stream_retry_attempts` retry is a separate layer
1600 // and is still in effect at the whole-turn level.)
1601 assert!(
1602 super::should_transparently_retry_stream(
1603 false,
1604 super::MAX_TRANSPARENT_STREAM_RETRIES - 1,
1605 false,
1606 ),
1607 "one short of the cap should still retry"
1608 );
1609 assert!(
1610 !super::should_transparently_retry_stream(
1611 false,
1612 super::MAX_TRANSPARENT_STREAM_RETRIES,
1613 false,
1614 ),
1615 "at the cap, no further transparent retries"
1616 );
1617 assert!(
1618 !super::should_transparently_retry_stream(
1619 false,
1620 super::MAX_TRANSPARENT_STREAM_RETRIES + 5,
1621 false,
1622 ),
1623 "well past the cap, definitely no transparent retries"
1624 );
1625 }
1626
1627 #[test]
1628 fn stream_retry_respects_cancellation() {
1629 // Cancellation overrides every other condition. If the user pressed
1630 // Esc / Ctrl-C, do not silently re-issue the request behind their back.
1631 assert!(
1632 !super::should_transparently_retry_stream(false, 0, true),
1633 "cancelled turn must not be transparently retried"
1634 );
1635 assert!(
1636 !super::should_transparently_retry_stream(false, 1, true),
1637 "cancelled turn must not be transparently retried even with budget"
1638 );
1639 }
1640
1641 #[test]
1642 fn stream_retry_threshold_relaxed_to_five() {
1643 // Case 1+4 from issue #103: the consecutive-error threshold for marking
1644 // the turn failed was relaxed from 3 → 5 in v0.6.7 because the new
1645 // HTTP/2 keepalive defaults make spurious decode errors rarer.
1646 // This test pins the constant so a future regression to 3 fails loudly.
1647 assert_eq!(
1648 super::MAX_STREAM_ERRORS_BEFORE_FAIL,
1649 5,
1650 "the consecutive-stream-error threshold should be 5; \
1651 lowering it back to 3 will fail mid-turn under transient flakiness"
1652 );
1653 // And a regression guard on the transparent-retry cap.
1654 assert_eq!(
1655 super::MAX_TRANSPARENT_STREAM_RETRIES,
1656 2,
1657 "transparent-retry cap should be 2; raising it risks hammering the \
1658 provider on real outages"
1659 );
1660 }
1661
1662 // === Issue #66: error taxonomy wired through engine + audit + capacity ===
1663
1664 /// A failed-tool audit entry must carry the typed `category` and `severity`
1665 /// fields derived from the underlying `ToolError`. This is what makes
1666 /// downstream tooling able to bucket failures without scraping the message
1667 /// string.
1668 #[test]
1669 fn tool_failure_audit_payload_carries_category_and_severity() {
1670 use crate::error_taxonomy::ErrorEnvelope;
1671 use crate::tools::spec::ToolError;
1672
1673 let error = ToolError::Timeout { seconds: 30 };
1674 let envelope: ErrorEnvelope = error.clone().into();
1675 let payload = json!({
1676 "event": "tool.result",
1677 "tool_id": "tool-1",
1678 "tool_name": "exec_shell",
1679 "success": false,
1680 "error": error.to_string(),
1681 "category": envelope.category.to_string(),
1682 "severity": envelope.severity.to_string(),
1683 });
1684
1685 assert_eq!(payload["category"], "timeout");
1686 assert_eq!(payload["severity"], "warning");
1687 assert_eq!(payload["success"], false);
1688 }
1689
1690 /// Capacity escalation sees `ErrorCategory::InvalidInput` as a context-overflow
1691 /// signal that must escalate even on the first failure (no consecutive
1692 /// requirement). The previous string-matching path scanned the message for
1693 /// "context length" — categories give us a typed contract instead.
1694 #[test]
1695 fn capacity_escalation_treats_invalid_input_as_overflow_signal() {
1696 use crate::error_taxonomy::ErrorCategory;
1697
1698 // Replays the categorization branches inside
1699 // `run_capacity_error_escalation_checkpoint`. Keeping the assertions on
1700 // the typed surface (slice of `ErrorCategory`) means this test fails
1701 // loudly if a future refactor reverts to substring matching.
1702 let categories: &[ErrorCategory] = &[ErrorCategory::InvalidInput];
1703 let has_context_overflow = categories.contains(&ErrorCategory::InvalidInput);
1704 assert!(has_context_overflow);
1705
1706 let only_transient = !categories.is_empty()
1707 && categories.iter().all(|c| {
1708 matches!(
1709 c,
1710 ErrorCategory::Network | ErrorCategory::RateLimit | ErrorCategory::Timeout
1711 )
1712 });
1713 assert!(!only_transient);
1714 }
1715
1716 /// Transient categories (network / rate limit / timeout) must NOT escalate by
1717 /// themselves — those resolve via the existing retry loop and shouldn't
1718 /// trigger a capacity-driven replan.
1719 #[test]
1720 fn capacity_escalation_skips_pure_transient_categories() {
1721 use crate::error_taxonomy::ErrorCategory;
1722
1723 let categories: &[ErrorCategory] = &[
1724 ErrorCategory::Network,
1725 ErrorCategory::RateLimit,
1726 ErrorCategory::Timeout,
1727 ];
1728 let has_context_overflow = categories.contains(&ErrorCategory::InvalidInput);
1729 assert!(!has_context_overflow);
1730
1731 let only_transient = !categories.is_empty()
1732 && categories.iter().all(|c| {
1733 matches!(
1734 c,
1735 ErrorCategory::Network | ErrorCategory::RateLimit | ErrorCategory::Timeout
1736 )
1737 });
1738 assert!(only_transient);
1739 }
1740
1741 // ── #136: post-edit LSP diagnostics hook ─────────────────────────────────
1742
1743 #[test]
1744 fn edited_paths_for_edit_file_returns_path() {
1745 let input = json!({ "path": "src/foo.rs", "search": "x", "replace": "y" });
1746 let paths = edited_paths_for_tool("edit_file", &input);
1747 assert_eq!(paths, vec![PathBuf::from("src/foo.rs")]);
1748 }
1749
1750 #[test]
1751 fn edited_paths_for_write_file_returns_path() {
1752 let input = json!({ "path": "src/bar.rs", "content": "fn main() {}" });
1753 let paths = edited_paths_for_tool("write_file", &input);
1754 assert_eq!(paths, vec![PathBuf::from("src/bar.rs")]);
1755 }
1756
1757 #[test]
1758 fn edited_paths_for_apply_patch_with_files_returns_each_path() {
1759 let input = json!({
1760 "files": [
1761 { "path": "a.rs", "content": "" },
1762 { "path": "b.rs", "content": "" }
1763 ]
1764 });
1765 let paths = edited_paths_for_tool("apply_patch", &input);
1766 assert_eq!(paths, vec![PathBuf::from("a.rs"), PathBuf::from("b.rs")]);
1767 }
1768
1769 #[test]
1770 fn edited_paths_for_apply_patch_with_diff_text_extracts_paths() {
1771 let input = json!({
1772 "patch": "--- a/foo.rs\n+++ b/foo.rs\n@@ -1 +1 @@\n-let x: i32 = 0;\n+let x: i32 = \"oops\";\n"
1773 });
1774 let paths = edited_paths_for_tool("apply_patch", &input);
1775 assert_eq!(paths, vec![PathBuf::from("foo.rs")]);
1776 }
1777
1778 #[test]
1779 fn edited_paths_for_unknown_tool_returns_empty() {
1780 let input = json!({ "path": "irrelevant.rs" });
1781 let paths = edited_paths_for_tool("read_file", &input);
1782 assert!(paths.is_empty());
1783 let paths = edited_paths_for_tool("grep_files", &input);
1784 assert!(paths.is_empty());
1785 }
1786
1787 #[test]
1788 fn parse_patch_paths_skips_dev_null() {
1789 let patch = "--- a/keep.rs\n+++ b/keep.rs\n--- a/deleted.rs\n+++ /dev/null\n";
1790 let paths = parse_patch_paths(patch);
1791 assert_eq!(paths, vec![PathBuf::from("keep.rs")]);
1792 }
1793
1794 #[tokio::test]
1795 async fn post_edit_hook_injects_diagnostics_message_before_next_request() {
1796 use crate::lsp::{Diagnostic, Language, Severity};
1797 use std::sync::Arc;
1798
1799 let tmp = tempdir().expect("tempdir");
1800 let workspace = tmp.path().to_path_buf();
1801 let target = workspace.join("src").join("main.rs");
1802 fs::create_dir_all(workspace.join("src")).unwrap();
1803 fs::write(&target, "let x: i32 = \"not a number\";").unwrap();
1804
1805 let lsp_config = crate::lsp::LspConfig::default();
1806 let engine_config = EngineConfig {
1807 workspace: workspace.clone(),
1808 lsp_config: Some(lsp_config),
1809 ..Default::default()
1810 };
1811 let (mut engine, _handle) = Engine::new(engine_config, &Config::default());
1812
1813 // Install a fake transport that always reports a type error.
1814 let fake = Arc::new(crate::lsp::tests::FakeTransport::new(vec![Diagnostic {
1815 line: 1,
1816 column: 14,
1817 severity: Severity::Error,
1818 message: "expected i32, found &str".to_string(),
1819 }]));
1820 engine
1821 .lsp_manager
1822 .install_test_transport(Language::Rust, fake)
1823 .await;
1824
1825 // Simulate the success path of an edit_file tool call.
1826 let input = json!({ "path": "src/main.rs", "search": "0", "replace": "\"not a number\"" });
1827 engine.run_post_edit_lsp_hook("edit_file", &input).await;
1828 assert_eq!(engine.pending_lsp_blocks.len(), 1);
1829
1830 // Flush prepares the synthetic message.
1831 let messages_before = engine.session.messages.len();
1832 engine.flush_pending_lsp_diagnostics().await;
1833 assert_eq!(engine.session.messages.len(), messages_before + 1);
1834
1835 let last = engine.session.messages.last().expect("message appended");
1836 assert_eq!(last.role, "user");
1837 let text = match &last.content[0] {
1838 crate::models::ContentBlock::Text { text, .. } => text.clone(),
1839 other => panic!("expected text block, got {other:?}"),
1840 };
1841 assert!(text.contains("<diagnostics file=\""));
1842 assert!(text.contains("ERROR [1:14] expected i32, found &str"));
1843 }
1844
1845 #[tokio::test]
1846 async fn post_edit_hook_is_silent_when_lsp_disabled() {
1847 let tmp = tempdir().expect("tempdir");
1848 let workspace = tmp.path().to_path_buf();
1849 let target = workspace.join("src").join("main.rs");
1850 fs::create_dir_all(workspace.join("src")).unwrap();
1851 fs::write(&target, "fn main() {}").unwrap();
1852
1853 let lsp_config = crate::lsp::LspConfig {
1854 enabled: false,
1855 ..Default::default()
1856 };
1857 let engine_config = EngineConfig {
1858 workspace: workspace.clone(),
1859 lsp_config: Some(lsp_config),
1860 ..Default::default()
1861 };
1862 let (mut engine, _handle) = Engine::new(engine_config, &Config::default());
1863
1864 let input = json!({ "path": "src/main.rs", "search": "x", "replace": "y" });
1865 engine.run_post_edit_lsp_hook("edit_file", &input).await;
1866 assert!(engine.pending_lsp_blocks.is_empty());
1867
1868 let messages_before = engine.session.messages.len();
1869 engine.flush_pending_lsp_diagnostics().await;
1870 assert_eq!(engine.session.messages.len(), messages_before);
1871 }
1872
1873 #[tokio::test]
1874 async fn post_edit_hook_skips_unknown_tool_names() {
1875 use crate::lsp::{Diagnostic, Language, Severity};
1876 use std::sync::Arc;
1877
1878 let tmp = tempdir().expect("tempdir");
1879 let engine_config = EngineConfig {
1880 workspace: tmp.path().to_path_buf(),
1881 lsp_config: Some(crate::lsp::LspConfig::default()),
1882 ..Default::default()
1883 };
1884 let (mut engine, _handle) = Engine::new(engine_config, &Config::default());
1885 let fake = Arc::new(crate::lsp::tests::FakeTransport::new(vec![Diagnostic {
1886 line: 1,
1887 column: 1,
1888 severity: Severity::Error,
1889 message: "should not be reported".to_string(),
1890 }]));
1891 engine
1892 .lsp_manager
1893 .install_test_transport(Language::Rust, fake.clone())
1894 .await;
1895
1896 let input = json!({ "path": "src/main.rs" });
1897 engine.run_post_edit_lsp_hook("read_file", &input).await;
1898 assert!(engine.pending_lsp_blocks.is_empty());
1899 assert_eq!(fake.call_count(), 0);
1900 }
1901
1901 lines RUST