| 1 | |
| 2 | |
| 3 | #[test] |
| 4 | fn subagent_results_are_summarized_before_parent_context_insertion() { |
| 5 | let long_result = "verified detail\n".repeat(1_000); |
| 6 | let output = ToolResult::success( |
| 7 | json!({ |
| 8 | "agent_id": "agent_1234abcd", |
| 9 | "agent_type": "explore", |
| 10 | "assignment": { |
| 11 | "objective": "Inspect the RLM rendering path and report the smallest fix." |
| 12 | }, |
| 13 | "model": "deepseek-v4-flash", |
| 14 | "status": "Completed", |
| 15 | "result": long_result, |
| 16 | "steps_taken": 12, |
| 17 | "duration_ms": 3456 |
| 18 | }) |
| 19 | .to_string(), |
| 20 | ); |
| 21 | |
| 22 | let context = compact_tool_result_for_context("deepseek-v4-pro", "agent", &output); |
| 23 | |
| 24 | assert!(context.contains("[sub-agent result summarized for parent context]")); |
| 25 | assert!(context.contains("agent_1234abcd (explore) status=Completed")); |
| 26 | assert!(context.contains("Inspect the RLM rendering path")); |
| 27 | assert!(context.contains("steps=12")); |
| 28 | assert!(context.len() < output.content.len()); |
| 29 | assert!(context.contains("self-report")); |
| 30 | assert!(context.contains("verify side effects")); |
| 31 | assert!(context.contains("verify side effects with `read`")); |
| 32 | assert!(!context.contains("read_file") && !context.contains("list_dir")); |
| 33 | assert!(context.contains("handle_read")); |
| 34 | // #6747: the guidance names only callable tools (no hidden `File`) and |
| 35 | // teaches the deferred `handle_read` activation path. |
| 36 | crate::tools::canonical_action::tests::assert_text_names_only_callable_tools( |
| 37 | "sub-agent summary guidance", |
| 38 | &super::context::subagent_summary_guidance(), |
| 39 | ); |
| 40 | } |
| 41 | |
| 42 | #[test] |
| 43 | fn wait_payloads_survive_parent_context_compaction() { |
| 44 | let raw = json!({ |
| 45 | "action": "wait", |
| 46 | "until": "all", |
| 47 | "all_settled": true, |
| 48 | "settled": [{"agent_id": "agent_1234abcd", "status": "Completed"}], |
| 49 | "still_running": [], |
| 50 | "waited_ms": 1234, |
| 51 | "timed_out": false, |
| 52 | "note": "joined the fan-out" |
| 53 | }) |
| 54 | .to_string(); |
| 55 | let output = ToolResult::success(raw.clone()); |
| 56 | |
| 57 | let context = compact_tool_result_for_context("deepseek-v4-pro", "agent", &output); |
| 58 | |
| 59 | assert!( |
| 60 | !context.contains("status=unknown"), |
| 61 | "a wait envelope must not be projected as an unknown snapshot: {context}" |
| 62 | ); |
| 63 | assert!(context.contains("agent_1234abcd")); |
| 64 | assert!(context.contains("still_running")); |
| 65 | assert!(context.contains("waited_ms")); |
| 66 | assert_eq!( |
| 67 | context, raw, |
| 68 | "small coordination payloads pass through verbatim" |
| 69 | ); |
| 70 | } |
| 71 | |
| 72 | #[test] |
| 73 | fn run_verifiers_results_are_structured_before_context_insertion() { |
| 74 | let noisy_failure = "node lint failure detail\n".repeat(300); |
| 75 | let noisy_success = "successful check output\n".repeat(300); |
| 76 | let output = ToolResult::success( |
| 77 | json!({ |
| 78 | "success": false, |
| 79 | "profile": "auto", |
| 80 | "level": "quick", |
| 81 | "workspace": "/repo", |
| 82 | "gate_count": 3, |
| 83 | "passed": 1, |
| 84 | "failed": 1, |
| 85 | "skipped": 1, |
| 86 | "summary": "1 passed, 1 failed, 1 skipped", |
| 87 | "gates": [ |
| 88 | { |
| 89 | "name": "rust-check", |
| 90 | "ecosystem": "rust", |
| 91 | "status": "passed", |
| 92 | "command": "cargo check --workspace --locked", |
| 93 | "cwd": "/repo", |
| 94 | "exit_code": 0, |
| 95 | "duration_ms": 110, |
| 96 | "stdout": noisy_success.clone(), |
| 97 | "stderr": "", |
| 98 | "stdout_truncated": false, |
| 99 | "stderr_truncated": false, |
| 100 | "skipped_reason": null |
| 101 | }, |
| 102 | { |
| 103 | "name": "node-lint", |
| 104 | "ecosystem": "node", |
| 105 | "status": "failed", |
| 106 | "command": "npm run lint", |
| 107 | "cwd": "/repo", |
| 108 | "exit_code": 1, |
| 109 | "duration_ms": 220, |
| 110 | "stdout": "", |
| 111 | "stderr": noisy_failure, |
| 112 | "stdout_truncated": false, |
| 113 | "stderr_truncated": false, |
| 114 | "skipped_reason": null |
| 115 | }, |
| 116 | { |
| 117 | "name": "python-pytest", |
| 118 | "ecosystem": "python", |
| 119 | "status": "skipped", |
| 120 | "command": "", |
| 121 | "cwd": "/repo", |
| 122 | "exit_code": null, |
| 123 | "duration_ms": 0, |
| 124 | "stdout": "", |
| 125 | "stderr": "", |
| 126 | "stdout_truncated": false, |
| 127 | "stderr_truncated": false, |
| 128 | "skipped_reason": "pytest is not installed" |
| 129 | } |
| 130 | ] |
| 131 | }) |
| 132 | .to_string(), |
| 133 | ); |
| 134 | |
| 135 | let context = compact_tool_result_for_context("deepseek-v4-pro", "run_verifiers", &output); |
| 136 | |
| 137 | assert!(context.contains("[run_verifiers result summarized for context]")); |
| 138 | assert!(context.contains("summary: 1 passed, 1 failed, 1 skipped")); |
| 139 | assert!(context.contains("selection: profile=auto, level=quick")); |
| 140 | assert!(context.contains("- node-lint (node): failed exit=1")); |
| 141 | assert!(context.contains("command: npm run lint")); |
| 142 | assert!(context.contains("- python-pytest (python): skipped")); |
| 143 | assert!(context.contains("pytest is not installed")); |
| 144 | assert!(context.contains("- rust-check (rust): passed exit=0")); |
| 145 | assert!(context.len() < output.content.len()); |
| 146 | assert!( |
| 147 | !context.contains(&noisy_success), |
| 148 | "successful gate stdout should not be copied into parent context" |
| 149 | ); |
| 150 | } |
| 151 | |
| 152 | #[test] |
| 153 | fn run_tests_results_are_structured_before_context_insertion() { |
| 154 | let stdout = "running test suite\n".repeat(500); |
| 155 | let stderr = "error[E0425]: cannot find value `missing`\n".repeat(500); |
| 156 | let output = ToolResult::success( |
| 157 | json!({ |
| 158 | "success": false, |
| 159 | "exit_code": 101, |
| 160 | "stdout": stdout, |
| 161 | "stderr": stderr, |
| 162 | "command": "(cd /repo && cargo test --workspace --all-features)" |
| 163 | }) |
| 164 | .to_string(), |
| 165 | ); |
| 166 | |
| 167 | let context = compact_tool_result_for_context("deepseek-v4-pro", "run_tests", &output); |
| 168 | |
| 169 | assert!(context.contains("[run_tests result summarized for context]")); |
| 170 | assert!(context.contains("status: failed, exit_code: 101")); |
| 171 | assert!(context.contains("cargo test --workspace --all-features")); |
| 172 | assert!(context.contains("error[E0425]")); |
| 173 | assert!(context.contains("running test suite")); |
| 174 | assert!(context.len() < output.content.len()); |
| 175 | } |
| 176 | |
| 177 | #[test] |
| 178 | fn task_gate_run_results_are_structured_before_context_insertion() { |
| 179 | let output = ToolResult::success( |
| 180 | json!({ |
| 181 | "gate": { |
| 182 | "id": "gate_abcd1234", |
| 183 | "gate": "clippy", |
| 184 | "command": "cargo clippy -p codewhale-tui --all-targets --all-features --locked -- -D warnings", |
| 185 | "cwd": "/repo", |
| 186 | "exit_code": 1, |
| 187 | "status": "failed", |
| 188 | "classification": "compile_failure", |
| 189 | "duration_ms": 5000, |
| 190 | "summary": "warning promoted to error in verifier.rs", |
| 191 | "log_path": "/repo/.codewhale/runtime/gate.log", |
| 192 | "recorded_at": "2026-06-01T12:00:00Z" |
| 193 | }, |
| 194 | "stdout_summary": "", |
| 195 | "stderr_summary": "warning promoted to error" |
| 196 | }) |
| 197 | .to_string(), |
| 198 | ); |
| 199 | |
| 200 | let context = compact_tool_result_for_context("deepseek-v4-pro", "task_gate_run", &output); |
| 201 | |
| 202 | assert!(context.contains("[task_gate_run result summarized for context]")); |
| 203 | assert!(context.contains("gate: clippy, status: failed, exit_code: 1")); |
| 204 | assert!(context.contains("cargo clippy -p codewhale-tui")); |
| 205 | assert!(context.contains("summary: warning promoted to error")); |
| 206 | assert!(context.contains("log_path: /repo/.codewhale/runtime/gate.log")); |
| 207 | } |
| 208 | |
| 209 | #[test] |
| 210 | fn refresh_system_prompt_leaves_working_set_out_of_system_prompt() { |
| 211 | let tmp = tempdir().expect("tempdir"); |
| 212 | fs::create_dir_all(tmp.path().join("src")).expect("mkdir"); |
| 213 | fs::write(tmp.path().join("src/lib.rs"), "pub fn sample() {}").expect("write"); |
| 214 | |
| 215 | let config = EngineConfig { |
| 216 | workspace: tmp.path().to_path_buf(), |
| 217 | ..Default::default() |
| 218 | }; |
| 219 | let (mut engine, _handle) = Engine::new(config, &Config::default()); |
| 220 | engine |
| 221 | .session |
| 222 | .working_set |
| 223 | .observe_user_message("please inspect src/lib.rs", tmp.path()); |
| 224 | |
| 225 | engine.refresh_system_prompt(); |
| 226 | |
| 227 | let prompt = match &engine.session.system_prompt { |
| 228 | Some(SystemPrompt::Text(text)) => text.clone(), |
| 229 | Some(SystemPrompt::Blocks(blocks)) => blocks |
| 230 | .iter() |
| 231 | .map(|block| block.text.as_str()) |
| 232 | .collect::<Vec<_>>() |
| 233 | .join("\n"), |
| 234 | None => panic!("expected system prompt"), |
| 235 | }; |
| 236 | assert!(!prompt.contains(WORKING_SET_SUMMARY_MARKER)); |
| 237 | } |
| 238 | |
| 239 | #[test] |
| 240 | fn working_set_reaches_model_as_turn_metadata() { |
| 241 | let tmp = tempdir().expect("tempdir"); |
| 242 | fs::create_dir_all(tmp.path().join("src")).expect("mkdir"); |
| 243 | fs::write(tmp.path().join("src/lib.rs"), "pub fn sample() {}").expect("write"); |
| 244 | |
| 245 | let config = EngineConfig { |
| 246 | workspace: tmp.path().to_path_buf(), |
| 247 | ..Default::default() |
| 248 | }; |
| 249 | let (mut engine, _handle) = Engine::new(config, &Config::default()); |
| 250 | engine |
| 251 | .session |
| 252 | .working_set |
| 253 | .observe_user_message("please inspect src/lib.rs", tmp.path()); |
| 254 | let user_msg = |
| 255 | engine.user_text_message_with_turn_metadata("please inspect src/lib.rs".to_string()); |
| 256 | engine.session.add_message(user_msg); |
| 257 | |
| 258 | let messages = engine.messages_with_turn_metadata(); |
| 259 | let last_block = messages |
| 260 | .first() |
| 261 | .and_then(|message| message.content.last()) |
| 262 | .expect("turn metadata block"); |
| 263 | let ContentBlock::Text { text, .. } = last_block else { |
| 264 | panic!("expected text metadata block"); |
| 265 | }; |
| 266 | assert!(text.starts_with("<turn_meta>\n")); |
| 267 | assert!(text.contains(WORKING_SET_SUMMARY_MARKER)); |
| 268 | assert!(text.contains("src/lib.rs")); |
| 269 | } |
| 270 | |
| 271 | #[test] |
| 272 | fn turn_metadata_includes_git_workspace_snapshot_in_repo() { |
| 273 | use crate::dependencies::ExternalTool; |
| 274 | |
| 275 | if !crate::dependencies::Git::available() { |
| 276 | return; |
| 277 | } |
| 278 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 279 | let root = tmp.path(); |
| 280 | let init = crate::dependencies::Git::output(&["init", "-q"], root); |
| 281 | if init.is_err() || !init.unwrap().status.success() { |
| 282 | return; |
| 283 | } |
| 284 | |
| 285 | let config = EngineConfig { |
| 286 | workspace: root.to_path_buf(), |
| 287 | ..Default::default() |
| 288 | }; |
| 289 | let (engine, _handle) = Engine::new(config, &Config::default()); |
| 290 | let user_msg = engine.user_text_message_with_turn_metadata("inspect repo state".to_string()); |
| 291 | let last_block = user_msg.content.last().expect("turn metadata block"); |
| 292 | let ContentBlock::Text { text, .. } = last_block else { |
| 293 | panic!("expected text metadata block"); |
| 294 | }; |
| 295 | |
| 296 | if let Some(snapshot) = crate::tui::workspace_context::collect(root) { |
| 297 | assert!( |
| 298 | text.contains(&format!("Git workspace: {snapshot}")), |
| 299 | "turn_meta should include git snapshot: {text}" |
| 300 | ); |
| 301 | } |
| 302 | } |
| 303 | |
| 304 | /// #5187 (k3-gap F3): the git snapshot line is emitted only when it actually |
| 305 | /// changes — an unchanged workspace must not re-emit the line (churning the |
| 306 | /// block's bytes and priming caution), a changed one must re-emit it once. |
| 307 | #[test] |
| 308 | fn turn_metadata_git_snapshot_emitted_only_on_change() { |
| 309 | use crate::dependencies::ExternalTool; |
| 310 | |
| 311 | if !crate::dependencies::Git::available() { |
| 312 | return; |
| 313 | } |
| 314 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 315 | let root = tmp.path(); |
| 316 | let init = crate::dependencies::Git::output(&["init", "-q"], root); |
| 317 | if init.is_err() || !init.unwrap().status.success() { |
| 318 | return; |
| 319 | } |
| 320 | if crate::tui::workspace_context::collect(root).is_none() { |
| 321 | return; |
| 322 | } |
| 323 | |
| 324 | let config = EngineConfig { |
| 325 | workspace: root.to_path_buf(), |
| 326 | ..Default::default() |
| 327 | }; |
| 328 | let (engine, _handle) = Engine::new(config, &Config::default()); |
| 329 | let meta_of = |msg: Message| -> String { |
| 330 | let ContentBlock::Text { text, .. } = msg.content.last().expect("turn metadata block") |
| 331 | else { |
| 332 | panic!("expected text metadata block"); |
| 333 | }; |
| 334 | text.clone() |
| 335 | }; |
| 336 | |
| 337 | let first = meta_of(engine.user_text_message_with_turn_metadata("first turn".to_string())); |
| 338 | assert!( |
| 339 | first.contains("Git workspace:"), |
| 340 | "first turn must emit the git snapshot: {first}" |
| 341 | ); |
| 342 | |
| 343 | let second = meta_of(engine.user_text_message_with_turn_metadata("second turn".to_string())); |
| 344 | assert!( |
| 345 | !second.contains("Git workspace:"), |
| 346 | "unchanged git state must not re-emit the snapshot line: {second}" |
| 347 | ); |
| 348 | |
| 349 | // Dirty the workspace: the snapshot changes, so the line is emitted once. |
| 350 | std::fs::write(root.join("turn-meta-gating.txt"), "changed").expect("write file"); |
| 351 | let third = meta_of(engine.user_text_message_with_turn_metadata("third turn".to_string())); |
| 352 | assert!( |
| 353 | third.contains("Git workspace:"), |
| 354 | "changed git state must re-emit the snapshot line: {third}" |
| 355 | ); |
| 356 | |
| 357 | let fourth = meta_of(engine.user_text_message_with_turn_metadata("fourth turn".to_string())); |
| 358 | assert!( |
| 359 | !fourth.contains("Git workspace:"), |
| 360 | "the re-emitted snapshot must be cached again: {fourth}" |
| 361 | ); |
| 362 | } |
| 363 | |
| 364 | #[test] |
| 365 | fn turn_metadata_includes_current_local_date_without_working_set() { |
| 366 | let tmp = tempdir().expect("tempdir"); |
| 367 | let config = EngineConfig { |
| 368 | model: "deepseek-v4-flash".to_string(), |
| 369 | workspace: tmp.path().to_path_buf(), |
| 370 | ..Default::default() |
| 371 | }; |
| 372 | let (mut engine, _handle) = Engine::new(config, &Config::default()); |
| 373 | let user_msg = engine.user_text_message_with_turn_metadata("what is today's date?".to_string()); |
| 374 | engine.session.add_message(user_msg); |
| 375 | |
| 376 | let messages = engine.messages_with_turn_metadata(); |
| 377 | let last_block = messages |
| 378 | .first() |
| 379 | .and_then(|message| message.content.last()) |
| 380 | .expect("turn metadata block"); |
| 381 | let ContentBlock::Text { text, .. } = last_block else { |
| 382 | panic!("expected text metadata block"); |
| 383 | }; |
| 384 | |
| 385 | let today = chrono::Local::now().format("%Y-%m-%d").to_string(); |
| 386 | assert!(text.starts_with("<turn_meta>\n")); |
| 387 | assert!(text.contains(&format!("Current local date: {today}"))); |
| 388 | assert!( |
| 389 | text.contains(&format!("Current workspace: {}", tmp.path().display())), |
| 390 | "workspace must remain in the block: {text}" |
| 391 | ); |
| 392 | assert!( |
| 393 | text.contains("Current permission posture: Ask"), |
| 394 | "the active posture must remain model-visible: {text}" |
| 395 | ); |
| 396 | // Turn-meta diet: no telemetry may re-enter the per-turn block. |
| 397 | for telemetry in [ |
| 398 | "Current model:", |
| 399 | "Current mode:", |
| 400 | "Input provenance:", |
| 401 | "Input authority:", |
| 402 | "Auto model route:", |
| 403 | "Auto reasoning effort:", |
| 404 | "Session token usage:", |
| 405 | "Active goal resource usage:", |
| 406 | "Active goal token budget:", |
| 407 | ] { |
| 408 | assert!( |
| 409 | !text.contains(telemetry), |
| 410 | "{telemetry} leaked into turn_meta: {text}" |
| 411 | ); |
| 412 | } |
| 413 | } |
| 414 | |
| 415 | #[test] |
| 416 | fn turn_metadata_surfaces_goal_budget_only_while_goal_active() { |
| 417 | let tmp = tempdir().expect("tempdir"); |
| 418 | let config = EngineConfig { |
| 419 | model: "deepseek-v4-flash".to_string(), |
| 420 | workspace: tmp.path().to_path_buf(), |
| 421 | ..Default::default() |
| 422 | }; |
| 423 | let (mut engine, _handle) = Engine::new(config, &Config::default()); |
| 424 | // Even with session usage recorded, the per-turn block must not surface |
| 425 | // it: totals/cache figures are UI telemetry, not model steering signal. |
| 426 | engine.session.total_usage.add(&Usage { |
| 427 | input_tokens: 1_200, |
| 428 | output_tokens: 300, |
| 429 | prompt_cache_hit_tokens: Some(800), |
| 430 | prompt_cache_miss_tokens: Some(400), |
| 431 | prompt_cache_write_tokens: Some(400), |
| 432 | ..Default::default() |
| 433 | }); |
| 434 | { |
| 435 | let mut goal = engine.config.goal_state.lock().expect("goal lock"); |
| 436 | goal.create("Finish telemetry visibility".to_string(), Some(2_000)) |
| 437 | .expect("create goal"); |
| 438 | goal.record_usage(1_000, 100); |
| 439 | } |
| 440 | |
| 441 | let user_msg = engine |
| 442 | .user_text_message_with_turn_metadata("continue the long-running release task".to_string()); |
| 443 | let last_block = user_msg.content.last().expect("turn metadata block"); |
| 444 | let ContentBlock::Text { text, .. } = last_block else { |
| 445 | panic!("expected text metadata block"); |
| 446 | }; |
| 447 | |
| 448 | // The goal budget stays (model pacing), and only while the goal is active. |
| 449 | assert!( |
| 450 | text.contains("Active goal token budget: 2000"), |
| 451 | "goal budget should be model-visible: {text}" |
| 452 | ); |
| 453 | // Usage/time deltas, rates, and continuation counts are telemetry. |
| 454 | for telemetry in [ |
| 455 | "Session token usage:", |
| 456 | "cache hits", |
| 457 | "cache writes", |
| 458 | "Active goal resource usage:", |
| 459 | "tok/s", |
| 460 | "continuations", |
| 461 | "50% budget", |
| 462 | ] { |
| 463 | assert!( |
| 464 | !text.contains(telemetry), |
| 465 | "{telemetry} leaked into turn_meta: {text}" |
| 466 | ); |
| 467 | } |
| 468 | |
| 469 | // Without an active goal the budget line must vanish entirely. |
| 470 | let tmp = tempdir().expect("tempdir"); |
| 471 | let config = EngineConfig { |
| 472 | model: "deepseek-v4-flash".to_string(), |
| 473 | workspace: tmp.path().to_path_buf(), |
| 474 | ..Default::default() |
| 475 | }; |
| 476 | let (engine, _handle) = Engine::new(config, &Config::default()); |
| 477 | let user_msg = engine.user_text_message_with_turn_metadata("no goal".to_string()); |
| 478 | let ContentBlock::Text { text, .. } = user_msg.content.last().expect("turn metadata block") |
| 479 | else { |
| 480 | panic!("expected text metadata block"); |
| 481 | }; |
| 482 | assert!( |
| 483 | !text.contains("Active goal token budget:"), |
| 484 | "budget must not be emitted when no goal is active: {text}" |
| 485 | ); |
| 486 | } |
| 487 | |
| 488 | #[test] |
| 489 | fn context_pressure_message_emits_only_at_warning_and_critical_thresholds() { |
| 490 | const WARNING: &str = "Context pressure: warning — ESCALATED: prefer /compact, narrow scope, or finish the current task"; |
| 491 | const CRITICAL: &str = "Context pressure: critical — CRITICAL: stop expanding scope; run /compact immediately or finish the current task"; |
| 492 | |
| 493 | assert_eq!(context_pressure_message(84.99), None); |
| 494 | assert_eq!(context_pressure_message(85.0), Some(WARNING)); |
| 495 | assert_eq!(context_pressure_message(94.99), Some(WARNING)); |
| 496 | assert_eq!(context_pressure_message(95.0), Some(CRITICAL)); |
| 497 | assert_eq!(context_pressure_message(100.0), Some(CRITICAL)); |
| 498 | |
| 499 | // Threshold labels steer a decision without exposing a continuously |
| 500 | // changing percentage, token count, or headroom value. |
| 501 | for line in [WARNING, CRITICAL] { |
| 502 | assert!(!line.contains('%'), "{line}"); |
| 503 | assert!(!line.contains("tokens"), "{line}"); |
| 504 | assert!(!line.contains("headroom"), "{line}"); |
| 505 | } |
| 506 | } |
| 507 | |
| 508 | #[test] |
| 509 | fn runtime_turn_metadata_condenses_non_authoritative_provenance_to_one_line() { |
| 510 | let tmp = tempdir().expect("tempdir"); |
| 511 | let config = EngineConfig { |
| 512 | workspace: tmp.path().to_path_buf(), |
| 513 | ..Default::default() |
| 514 | }; |
| 515 | let (engine, _handle) = Engine::new(config, &Config::default()); |
| 516 | let msg = engine.runtime_text_message_with_turn_metadata( |
| 517 | "改吧".to_string(), |
| 518 | UserInputProvenance::AssistantGenerated, |
| 519 | ); |
| 520 | let last_block = msg.content.last().expect("turn metadata block"); |
| 521 | let ContentBlock::Text { text, .. } = last_block else { |
| 522 | panic!("expected text metadata block"); |
| 523 | }; |
| 524 | |
| 525 | // Reduced authority on a non-external turn is the sole signal: one |
| 526 | // condensed line, not the former two-line provenance/authority pair. |
| 527 | assert!( |
| 528 | text.contains("Input provenance: assistant_generated (non-authoritative)"), |
| 529 | "{text}" |
| 530 | ); |
| 531 | assert!(!text.contains("Input authority:"), "{text}"); |
| 532 | assert!(!text.contains("Input provenance: external_user"), "{text}"); |
| 533 | } |
| 534 | |
| 535 | #[test] |
| 536 | fn turn_metadata_omits_route_and_reasoning_effort_telemetry() { |
| 537 | let tmp = tempdir().expect("tempdir"); |
| 538 | let config = EngineConfig { |
| 539 | workspace: tmp.path().to_path_buf(), |
| 540 | ..Default::default() |
| 541 | }; |
| 542 | let (engine, _handle) = Engine::new(config, &Config::default()); |
| 543 | |
| 544 | let user_msg = engine.user_text_message_with_turn_metadata_for_route( |
| 545 | "debug this regression".to_string(), |
| 546 | "deepseek-v4-pro", |
| 547 | true, |
| 548 | Some("max"), |
| 549 | true, |
| 550 | ); |
| 551 | let last_block = user_msg.content.last().expect("turn metadata block"); |
| 552 | let ContentBlock::Text { text, .. } = last_block else { |
| 553 | panic!("expected text metadata block"); |
| 554 | }; |
| 555 | |
| 556 | // Model, auto-route, and auto-reasoning-effort lines were pure telemetry |
| 557 | // and must never re-enter the per-turn block. |
| 558 | assert!(!text.contains("Current model:"), "{text}"); |
| 559 | assert!(!text.contains("Auto model route:"), "{text}"); |
| 560 | assert!(!text.contains("Auto reasoning effort:"), "{text}"); |
| 561 | assert!(!text.contains("debug this regression")); |
| 562 | assert!( |
| 563 | text.starts_with( |
| 564 | "<turn_meta> |
| 565 | Current local date:" |
| 566 | ), |
| 567 | "{text}" |
| 568 | ); |
| 569 | } |
| 570 | |
| 571 | #[test] |
| 572 | fn turn_metadata_keeps_stable_fields_while_pressure_reports_live_estimates() { |
| 573 | // Live estimates belong in appended turn metadata, never in the pinned |
| 574 | // system prefix. Unrelated metadata remains stable as the transcript grows. |
| 575 | let tmp = tempdir().expect("tempdir"); |
| 576 | let config = EngineConfig { |
| 577 | model: "deepseek-v4-flash".to_string(), |
| 578 | workspace: tmp.path().to_path_buf(), |
| 579 | ..Default::default() |
| 580 | }; |
| 581 | let (mut engine, _handle) = Engine::new(config, &Config::default()); |
| 582 | |
| 583 | // Use explicit route limits so the fixture exercises the critical band |
| 584 | // without depending on a model catalog entry or provider default. |
| 585 | engine.session.messages.push(Message { |
| 586 | role: Role::User, |
| 587 | content: vec![ContentBlock::Text { |
| 588 | text: "x".repeat(100_000), |
| 589 | cache_control: None, |
| 590 | }], |
| 591 | }); |
| 592 | let prompt_context = NextTurnPromptContext::for_planned_turn( |
| 593 | ProviderKind::Deepseek, |
| 594 | "deepseek-v4-flash".to_string(), |
| 595 | Some(codewhale_config::route::RouteLimits { |
| 596 | context_tokens: Some(10_000), |
| 597 | input_tokens: None, |
| 598 | output_tokens: Some(512), |
| 599 | }), |
| 600 | AppMode::Agent, |
| 601 | None, |
| 602 | crate::tools::goal::GoalStatus::Active, |
| 603 | None, |
| 604 | false, |
| 605 | None, |
| 606 | ); |
| 607 | |
| 608 | let meta_of = |msg: &Message| -> String { |
| 609 | let ContentBlock::Text { text, .. } = msg.content.last().expect("turn metadata block") |
| 610 | else { |
| 611 | panic!("expected text metadata block"); |
| 612 | }; |
| 613 | text.clone() |
| 614 | }; |
| 615 | let message_for = |engine: &Engine| { |
| 616 | engine.user_text_message_from_snapshot( |
| 617 | "stable input".to_string(), |
| 618 | &prompt_context.model, |
| 619 | false, |
| 620 | None, |
| 621 | false, |
| 622 | UserInputProvenance::ExternalUser, |
| 623 | TurnMetadataSnapshot { |
| 624 | prompt_context: &prompt_context, |
| 625 | system_prompt: None, |
| 626 | approval_mode: engine.session.approval_mode, |
| 627 | working_set: &engine.session.working_set, |
| 628 | policy_narrowing: None, |
| 629 | }, |
| 630 | ) |
| 631 | }; |
| 632 | |
| 633 | let first = message_for(&engine); |
| 634 | let first_meta = meta_of(&first); |
| 635 | assert!( |
| 636 | !first_meta.contains("Context pressure:"), |
| 637 | "automatic continuity must not ask the user to manage context: {first_meta}" |
| 638 | ); |
| 639 | assert!(!first_meta.contains("/compact")); |
| 640 | engine.config.compaction.enabled = false; |
| 641 | let first = message_for(&engine); |
| 642 | let first_meta = meta_of(&first); |
| 643 | assert!( |
| 644 | first_meta.contains("Context pressure: critical"), |
| 645 | "fixture must exercise the pressure line: {first_meta}" |
| 646 | ); |
| 647 | |
| 648 | engine.session.add_message(first); |
| 649 | let second = message_for(&engine); |
| 650 | let second_meta = meta_of(&second); |
| 651 | let without_pressure = |metadata: &str| { |
| 652 | metadata |
| 653 | .lines() |
| 654 | .filter(|line| !line.contains("Context pressure:")) |
| 655 | .collect::<Vec<_>>() |
| 656 | .join("\n") |
| 657 | }; |
| 658 | assert_eq!( |
| 659 | without_pressure(&first_meta), |
| 660 | without_pressure(&second_meta) |
| 661 | ); |
| 662 | assert!(second_meta.contains("Estimated input:")); |
| 663 | assert!(second_meta.contains("Making room automatically is off")); |
| 664 | } |
| 665 | |
| 666 | #[tokio::test] |
| 667 | async fn interrupted_turn_names_surviving_background_shell_jobs() { |
| 668 | // DGF-03 (dogfood 2026-08-02): Esc says "Turn interrupted" while |
| 669 | // detached background shells keep writing files. The interrupt path |
| 670 | // must name the survivors so the copy stops lying about what stopped. |
| 671 | let tmp = tempdir().expect("tempdir"); |
| 672 | let marker = tmp.path().join("survivor-marker.txt"); |
| 673 | let shell_manager = crate::tools::shell::new_shared_shell_manager(tmp.path().to_path_buf()); |
| 674 | |
| 675 | let runtime_services = crate::tools::spec::RuntimeToolServices { |
| 676 | shell_manager: Some(shell_manager.clone()), |
| 677 | ..crate::tools::spec::RuntimeToolServices::default() |
| 678 | }; |
| 679 | let engine_config = EngineConfig { |
| 680 | model: "deepseek-v4-flash".to_string(), |
| 681 | workspace: tmp.path().to_path_buf(), |
| 682 | snapshots_enabled: false, |
| 683 | terminal_chrome_enabled: false, |
| 684 | runtime_services, |
| 685 | ..EngineConfig::default() |
| 686 | }; |
| 687 | let (engine, handle) = Engine::new(engine_config, &Config::default()); |
| 688 | |
| 689 | // Background sleep-then-write: still running at interrupt time, and its |
| 690 | // write lands only after the UI would have said "interrupted". Stamp the |
| 691 | // engine's immutable session owner so a replacement session cannot see or |
| 692 | // control it. |
| 693 | let task_id = { |
| 694 | let mut manager = shell_manager.lock().expect("shell manager"); |
| 695 | let result = manager |
| 696 | .execute_with_options_env_for_session( |
| 697 | &format!("sleep 5 && touch '{}'", marker.display()), |
| 698 | None, |
| 699 | 60_000, |
| 700 | true, |
| 701 | None, |
| 702 | false, |
| 703 | None, |
| 704 | HashMap::new(), |
| 705 | &engine.session.id, |
| 706 | ) |
| 707 | .expect("spawn background job"); |
| 708 | result.task_id.expect("background task id") |
| 709 | }; |
| 710 | assert!( |
| 711 | !marker.exists(), |
| 712 | "marker must not exist before the interrupt" |
| 713 | ); |
| 714 | |
| 715 | engine.emit_interrupted_survivor_status().await; |
| 716 | |
| 717 | let mut events = handle.rx_event.write().await; |
| 718 | let mut survivor_line = None; |
| 719 | while let Ok(event) = events.try_recv() { |
| 720 | if let Event::Status { message } = event |
| 721 | && message.contains("background shell job") |
| 722 | { |
| 723 | survivor_line = Some(message); |
| 724 | } |
| 725 | } |
| 726 | let survivor_line = survivor_line.expect("interrupt must name surviving background jobs"); |
| 727 | assert!(survivor_line.contains(&task_id), "{survivor_line}"); |
| 728 | assert!( |
| 729 | survivor_line.contains("may still write files"), |
| 730 | "{survivor_line}" |
| 731 | ); |
| 732 | assert!( |
| 733 | !marker.exists(), |
| 734 | "the honesty line must fire while the job is still running" |
| 735 | ); |
| 736 | |
| 737 | // Cleanup: don't leave the sleeper running after the test. |
| 738 | let _ = shell_manager.lock().expect("shell manager").kill(&task_id); |
| 739 | } |
| 740 | |
| 741 | /// R6 injection-size regression: the per-turn `<turn_meta>` block, built |
| 742 | /// (never sent) from the same snapshot path production uses. Measured 254B |
| 743 | /// on 2026-08-02; the unavailable-backend qualifier adds 48B (Linux without |
| 744 | /// bwrap, all Windows). Ceiling is that host's measured size +10% |
| 745 | /// so growth is a reviewed act. |
| 746 | const TURN_META_BYTE_CEILING: usize = 333; |
| 747 | |
| 748 | #[test] |
| 749 | fn turn_meta_block_stays_within_measured_ceiling() { |
| 750 | let tmp = tempdir().expect("tempdir"); |
| 751 | let config = EngineConfig { |
| 752 | model: "deepseek-v4-flash".to_string(), |
| 753 | workspace: tmp.path().to_path_buf(), |
| 754 | ..Default::default() |
| 755 | }; |
| 756 | let (engine, _handle) = Engine::new(config, &Config::default()); |
| 757 | let prompt_context = NextTurnPromptContext::for_planned_turn( |
| 758 | ProviderKind::Deepseek, |
| 759 | "deepseek-v4-flash".to_string(), |
| 760 | None, |
| 761 | AppMode::Agent, |
| 762 | None, |
| 763 | crate::tools::goal::GoalStatus::Active, |
| 764 | None, |
| 765 | false, |
| 766 | None, |
| 767 | ); |
| 768 | let message = engine.user_text_message_from_snapshot( |
| 769 | "hello".to_string(), |
| 770 | &prompt_context.model, |
| 771 | false, |
| 772 | None, |
| 773 | false, |
| 774 | UserInputProvenance::ExternalUser, |
| 775 | TurnMetadataSnapshot { |
| 776 | prompt_context: &prompt_context, |
| 777 | system_prompt: None, |
| 778 | approval_mode: engine.session.approval_mode, |
| 779 | working_set: &engine.session.working_set, |
| 780 | policy_narrowing: None, |
| 781 | }, |
| 782 | ); |
| 783 | let ContentBlock::Text { text, .. } = message.content.last().expect("turn metadata block") |
| 784 | else { |
| 785 | panic!("expected text metadata block"); |
| 786 | }; |
| 787 | assert!( |
| 788 | text.len() <= TURN_META_BYTE_CEILING, |
| 789 | "turn_meta grew past its reviewed ceiling: {}B > {TURN_META_BYTE_CEILING}B. If deliberate, re-measure and raise the ceiling in the same commit.", |
| 790 | text.len() |
| 791 | ); |
| 792 | } |
| 793 | |
| 794 | #[test] |
| 795 | fn turn_metadata_names_the_effective_sandbox_posture() { |
| 796 | // DGF-02 (dogfood 2026-08-02): the model must know its own sandbox |
| 797 | // posture, derived from the same resolver tool execution uses, so an |
| 798 | // approved-then-sandbox-blocked write never reads as a mystery failure. |
| 799 | let tmp = tempdir().expect("tempdir"); |
| 800 | let config = EngineConfig { |
| 801 | model: "deepseek-v4-flash".to_string(), |
| 802 | workspace: tmp.path().to_path_buf(), |
| 803 | ..Default::default() |
| 804 | }; |
| 805 | let (mut engine, _handle) = Engine::new(config, &Config::default()); |
| 806 | |
| 807 | let meta_for_mode = |engine: &Engine, mode: AppMode| -> String { |
| 808 | let prompt_context = NextTurnPromptContext::for_planned_turn( |
| 809 | ProviderKind::Deepseek, |
| 810 | "deepseek-v4-flash".to_string(), |
| 811 | None, |
| 812 | mode, |
| 813 | None, |
| 814 | crate::tools::goal::GoalStatus::Active, |
| 815 | None, |
| 816 | false, |
| 817 | None, |
| 818 | ); |
| 819 | let message = engine.user_text_message_from_snapshot( |
| 820 | "hello".to_string(), |
| 821 | &prompt_context.model, |
| 822 | false, |
| 823 | None, |
| 824 | false, |
| 825 | UserInputProvenance::ExternalUser, |
| 826 | TurnMetadataSnapshot { |
| 827 | prompt_context: &prompt_context, |
| 828 | system_prompt: None, |
| 829 | approval_mode: engine.session.approval_mode, |
| 830 | working_set: &engine.session.working_set, |
| 831 | policy_narrowing: None, |
| 832 | }, |
| 833 | ); |
| 834 | let ContentBlock::Text { text, .. } = message.content.last().expect("turn metadata block") |
| 835 | else { |
| 836 | panic!("expected text metadata block"); |
| 837 | }; |
| 838 | text.clone() |
| 839 | }; |
| 840 | |
| 841 | let agent_meta = meta_for_mode(&engine, AppMode::Agent); |
| 842 | assert!( |
| 843 | agent_meta.contains("Current sandbox posture: workspace-write"), |
| 844 | "{agent_meta}" |
| 845 | ); |
| 846 | |
| 847 | // Plan mode must surface the read-only clamp without promising that this |
| 848 | // non-interactive posture can open an escalation prompt. |
| 849 | let plan_meta = meta_for_mode(&engine, AppMode::Plan); |
| 850 | assert!( |
| 851 | plan_meta.contains( |
| 852 | "Current sandbox posture: read-only (shell writes are blocked; ordinary approval does not change this)" |
| 853 | ), |
| 854 | "{plan_meta}" |
| 855 | ); |
| 856 | |
| 857 | // Pin deterministic states instead of branching on the CI host. The |
| 858 | // production value is also captured once at engine construction. |
| 859 | engine.sandbox_enforcement = crate::sandbox::policy::SandboxEnforcement::LocalOs; |
| 860 | let local_meta = meta_for_mode(&engine, AppMode::Agent); |
| 861 | assert!( |
| 862 | local_meta.contains("local OS sandbox applied"), |
| 863 | "{local_meta}" |
| 864 | ); |
| 865 | |
| 866 | engine.sandbox_enforcement = crate::sandbox::policy::SandboxEnforcement::Unavailable; |
| 867 | let unavailable_meta = meta_for_mode(&engine, AppMode::Agent); |
| 868 | assert!( |
| 869 | unavailable_meta.contains("policy only; no execution sandbox available"), |
| 870 | "{unavailable_meta}" |
| 871 | ); |
| 872 | |
| 873 | engine.sandbox_enforcement = crate::sandbox::policy::SandboxEnforcement::ExternalBackend; |
| 874 | let external_meta = meta_for_mode(&engine, AppMode::Agent); |
| 875 | assert!( |
| 876 | external_meta.contains("workspace-write policy"), |
| 877 | "{external_meta}" |
| 878 | ); |
| 879 | assert!( |
| 880 | external_meta.contains("external execution backend configured"), |
| 881 | "{external_meta}" |
| 882 | ); |
| 883 | assert!( |
| 884 | external_meta.contains("isolation unverified by Codewhale"), |
| 885 | "{external_meta}" |
| 886 | ); |
| 887 | assert!( |
| 888 | !external_meta.contains("writes inside the workspace"), |
| 889 | "{external_meta}" |
| 890 | ); |
| 891 | } |
| 892 | |
| 893 | #[test] |
| 894 | fn provenance_gate_preserves_standing_yolo_for_runtime_and_subagent_continuations() { |
| 895 | let all_provenances = [ |
| 896 | UserInputProvenance::ExternalUser, |
| 897 | UserInputProvenance::Runtime, |
| 898 | UserInputProvenance::SubAgentHandoff, |
| 899 | UserInputProvenance::ImportedTranscript, |
| 900 | UserInputProvenance::MemoryRecall, |
| 901 | UserInputProvenance::AssistantGenerated, |
| 902 | ]; |
| 903 | let inheriting_provenances = [ |
| 904 | UserInputProvenance::ExternalUser, |
| 905 | UserInputProvenance::Runtime, |
| 906 | UserInputProvenance::SubAgentHandoff, |
| 907 | ]; |
| 908 | |
| 909 | for provenance in all_provenances { |
| 910 | let policy = effective_input_policy( |
| 911 | provenance, |
| 912 | AppMode::Agent, |
| 913 | "continue", |
| 914 | true, |
| 915 | true, |
| 916 | true, |
| 917 | ApprovalMode::Auto, |
| 918 | ); |
| 919 | |
| 920 | if inheriting_provenances.contains(&provenance) { |
| 921 | assert_eq!(policy.mode, AppMode::Agent, "{provenance:?}"); |
| 922 | assert!(policy.allow_shell, "{provenance:?}"); |
| 923 | assert!(policy.trust_mode, "{provenance:?}"); |
| 924 | assert!(policy.auto_approve, "{provenance:?}"); |
| 925 | assert_eq!(policy.approval_mode, ApprovalMode::Auto, "{provenance:?}"); |
| 926 | assert!(policy.status().is_none(), "{provenance:?}"); |
| 927 | } else { |
| 928 | assert_eq!(policy.mode, AppMode::Agent, "{provenance:?}"); |
| 929 | assert!(policy.allow_shell, "{provenance:?}"); |
| 930 | assert!(!policy.trust_mode, "{provenance:?}"); |
| 931 | assert!(!policy.auto_approve, "{provenance:?}"); |
| 932 | assert_eq!( |
| 933 | policy.approval_mode, |
| 934 | ApprovalMode::Suggest, |
| 935 | "{provenance:?}" |
| 936 | ); |
| 937 | assert!( |
| 938 | policy.status().as_deref().is_some_and( |
| 939 | |status| status.contains("cannot inherit standing auto-approval authority") |
| 940 | ), |
| 941 | "{provenance:?}" |
| 942 | ); |
| 943 | } |
| 944 | } |
| 945 | } |
| 946 | |
| 947 | #[test] |
| 948 | fn provenance_gate_never_invents_auto_authority_for_non_yolo_sessions() { |
| 949 | let all_provenances = [ |
| 950 | UserInputProvenance::ExternalUser, |
| 951 | UserInputProvenance::Runtime, |
| 952 | UserInputProvenance::SubAgentHandoff, |
| 953 | UserInputProvenance::ImportedTranscript, |
| 954 | UserInputProvenance::MemoryRecall, |
| 955 | UserInputProvenance::AssistantGenerated, |
| 956 | ]; |
| 957 | |
| 958 | for provenance in all_provenances { |
| 959 | let policy = effective_input_policy( |
| 960 | provenance, |
| 961 | AppMode::Agent, |
| 962 | "continue", |
| 963 | true, |
| 964 | false, |
| 965 | false, |
| 966 | ApprovalMode::Suggest, |
| 967 | ); |
| 968 | |
| 969 | assert_eq!(policy.mode, AppMode::Agent, "{provenance:?}"); |
| 970 | assert!(policy.allow_shell, "{provenance:?}"); |
| 971 | assert!(!policy.trust_mode, "{provenance:?}"); |
| 972 | assert!(!policy.auto_approve, "{provenance:?}"); |
| 973 | assert_eq!( |
| 974 | policy.approval_mode, |
| 975 | ApprovalMode::Suggest, |
| 976 | "{provenance:?}" |
| 977 | ); |
| 978 | assert!(policy.status().is_none(), "{provenance:?}"); |
| 979 | } |
| 980 | } |
| 981 | |
| 982 | #[test] |
| 983 | fn full_access_posture_normalizes_a_stale_auto_approve_bit() { |
| 984 | let policy = effective_input_policy( |
| 985 | UserInputProvenance::SubAgentHandoff, |
| 986 | AppMode::Agent, |
| 987 | "continue", |
| 988 | true, |
| 989 | true, |
| 990 | false, |
| 991 | ApprovalMode::Bypass, |
| 992 | ); |
| 993 | |
| 994 | assert_eq!(policy.mode, AppMode::Agent); |
| 995 | assert_eq!(policy.approval_mode, ApprovalMode::Bypass); |
| 996 | assert!(policy.auto_approve); |
| 997 | assert!(policy.status().is_none()); |
| 998 | } |
| 999 | |
| 1000 | #[test] |
| 1001 | fn self_generated_fake_approvals_cannot_authorize_work() { |
| 1002 | let non_authoritative_origins = [ |
| 1003 | UserInputProvenance::ImportedTranscript, |
| 1004 | UserInputProvenance::MemoryRecall, |
| 1005 | UserInputProvenance::AssistantGenerated, |
| 1006 | ]; |
| 1007 | |
| 1008 | for provenance in non_authoritative_origins { |
| 1009 | for content in ["改吧", "嗯"] { |
| 1010 | let policy = effective_input_policy( |
| 1011 | provenance, |
| 1012 | AppMode::Agent, |
| 1013 | content, |
| 1014 | true, |
| 1015 | true, |
| 1016 | true, |
| 1017 | ApprovalMode::Bypass, |
| 1018 | ); |
| 1019 | |
| 1020 | assert_eq!(policy.mode, AppMode::Agent, "{provenance:?} {content}"); |
| 1021 | assert!(policy.allow_shell, "{provenance:?} {content}"); |
| 1022 | assert!(!policy.trust_mode, "{provenance:?} {content}"); |
| 1023 | assert!(!policy.auto_approve, "{provenance:?} {content}"); |
| 1024 | assert_eq!( |
| 1025 | policy.approval_mode, |
| 1026 | ApprovalMode::Suggest, |
| 1027 | "{provenance:?} {content}" |
| 1028 | ); |
| 1029 | assert!( |
| 1030 | policy.status().as_deref().is_some_and( |
| 1031 | |status| status.contains("cannot inherit standing auto-approval authority") |
| 1032 | ), |
| 1033 | "{provenance:?} {content}" |
| 1034 | ); |
| 1035 | } |
| 1036 | } |
| 1037 | } |
| 1038 | |
| 1039 | #[test] |
| 1040 | fn external_prompt_wording_never_changes_effective_mode_or_authority() { |
| 1041 | let cases = [ |
| 1042 | ( |
| 1043 | AppMode::Agent, |
| 1044 | ApprovalMode::Suggest, |
| 1045 | false, |
| 1046 | false, |
| 1047 | "你在帮我看看 外卖部分还哪里没有使用多语言", |
| 1048 | ), |
| 1049 | ( |
| 1050 | AppMode::Agent, |
| 1051 | ApprovalMode::Bypass, |
| 1052 | true, |
| 1053 | true, |
| 1054 | "check the failing tests and review the logs", |
| 1055 | ), |
| 1056 | ( |
| 1057 | AppMode::Agent, |
| 1058 | ApprovalMode::Suggest, |
| 1059 | false, |
| 1060 | false, |
| 1061 | "检查外卖模块并修复缺少的多语言注入", |
| 1062 | ), |
| 1063 | ]; |
| 1064 | |
| 1065 | for (requested_mode, approval_mode, trust_mode, auto_approve, content) in cases { |
| 1066 | let policy = effective_input_policy( |
| 1067 | UserInputProvenance::ExternalUser, |
| 1068 | requested_mode, |
| 1069 | content, |
| 1070 | true, |
| 1071 | trust_mode, |
| 1072 | auto_approve, |
| 1073 | approval_mode, |
| 1074 | ); |
| 1075 | |
| 1076 | assert_eq!(policy.mode, requested_mode, "{content}"); |
| 1077 | assert_eq!(policy.trust_mode, trust_mode, "{content}"); |
| 1078 | assert_eq!(policy.auto_approve, auto_approve, "{content}"); |
| 1079 | assert_eq!(policy.approval_mode, approval_mode, "{content}"); |
| 1080 | assert!(policy.allow_shell, "{content}"); |
| 1081 | assert!(policy.dynamic_active_tools.is_empty(), "{content}"); |
| 1082 | assert!(policy.status().is_none(), "{content}"); |
| 1083 | } |
| 1084 | } |
| 1085 | |
| 1086 | #[test] |
| 1087 | fn external_user_wording_does_not_downgrade_standing_authority() { |
| 1088 | let review_wording = effective_input_policy( |
| 1089 | UserInputProvenance::ExternalUser, |
| 1090 | AppMode::Agent, |
| 1091 | "你在帮我看看 外卖部分还哪里没有使用多语言 我看看要不要加", |
| 1092 | true, |
| 1093 | true, |
| 1094 | true, |
| 1095 | ApprovalMode::Bypass, |
| 1096 | ); |
| 1097 | assert_eq!(review_wording.mode, AppMode::Agent); |
| 1098 | assert!(review_wording.allow_shell); |
| 1099 | assert!(review_wording.trust_mode); |
| 1100 | assert!(review_wording.auto_approve); |
| 1101 | assert_eq!(review_wording.approval_mode, ApprovalMode::Bypass); |
| 1102 | assert!( |
| 1103 | review_wording.status().is_none(), |
| 1104 | "external user wording must not content-downgrade standing authority" |
| 1105 | ); |
| 1106 | |
| 1107 | let later_user_instruction = effective_input_policy( |
| 1108 | UserInputProvenance::ExternalUser, |
| 1109 | AppMode::Agent, |
| 1110 | "需要修复下", |
| 1111 | true, |
| 1112 | true, |
| 1113 | true, |
| 1114 | ApprovalMode::Bypass, |
| 1115 | ); |
| 1116 | assert_eq!(later_user_instruction.mode, AppMode::Agent); |
| 1117 | assert!(later_user_instruction.allow_shell); |
| 1118 | assert!(later_user_instruction.trust_mode); |
| 1119 | assert!(later_user_instruction.auto_approve); |
| 1120 | assert_eq!(later_user_instruction.approval_mode, ApprovalMode::Bypass); |
| 1121 | assert!( |
| 1122 | later_user_instruction.status().is_none(), |
| 1123 | "a fresh external write instruction must not inherit the prior review-only downgrade" |
| 1124 | ); |
| 1125 | } |
| 1126 | |
| 1127 | #[test] |
| 1128 | fn turn_metadata_leaves_mode_entirely_to_runtime_policy() { |
| 1129 | // Mode permissions and capabilities are already concrete in runtime policy |
| 1130 | // and the live tool catalog. Prompt prose must not create a parallel mode. |
| 1131 | let tmp = tempdir().expect("tempdir"); |
| 1132 | let config = EngineConfig { |
| 1133 | workspace: tmp.path().to_path_buf(), |
| 1134 | ..Default::default() |
| 1135 | }; |
| 1136 | let (mut engine, _handle) = Engine::new(config, &Config::default()); |
| 1137 | engine.current_mode = AppMode::Plan; |
| 1138 | |
| 1139 | let user_msg = engine.user_text_message_with_turn_metadata_for_route( |
| 1140 | "explain the refactor plan before editing".to_string(), |
| 1141 | "deepseek-v4-flash", |
| 1142 | false, |
| 1143 | None, |
| 1144 | false, |
| 1145 | ); |
| 1146 | let last_block = user_msg.content.last().expect("turn metadata block"); |
| 1147 | let ContentBlock::Text { text, .. } = last_block else { |
| 1148 | panic!("expected text metadata block"); |
| 1149 | }; |
| 1150 | |
| 1151 | assert!(!text.contains("Current mode:"), "got: {text}"); |
| 1152 | assert!( |
| 1153 | !text.contains("Current mode policy"), |
| 1154 | "mode doctrine must not re-enter turn_meta: {text}" |
| 1155 | ); |
| 1156 | assert!( |
| 1157 | !text.contains("##### Mode: Plan"), |
| 1158 | "mode overlay text must not re-enter turn_meta: {text}" |
| 1159 | ); |
| 1160 | assert!( |
| 1161 | !text.contains("All writes, patches, shell commands,"), |
| 1162 | "mode doctrine must not re-enter turn_meta: {text}" |
| 1163 | ); |
| 1164 | } |
| 1165 | |
| 1166 | #[test] |
| 1167 | fn turn_metadata_projects_permission_posture_as_fact_only() { |
| 1168 | // #4780 + turn-meta diet: the active posture remains an actionable fact. |
| 1169 | // Never adds one actionable constraint so the model cannot waste a turn |
| 1170 | // asking for an approval the host is configured not to provide. |
| 1171 | use ApprovalMode; |
| 1172 | |
| 1173 | let cases = [ |
| 1174 | (ApprovalMode::Suggest, "Ask"), |
| 1175 | (ApprovalMode::Auto, "Auto-Review"), |
| 1176 | (ApprovalMode::Bypass, "Full Access"), |
| 1177 | (ApprovalMode::Never, "Never"), |
| 1178 | ]; |
| 1179 | |
| 1180 | for (approval_mode, posture) in cases { |
| 1181 | let tmp = tempdir().expect("tempdir"); |
| 1182 | let config = EngineConfig { |
| 1183 | workspace: tmp.path().to_path_buf(), |
| 1184 | ..Default::default() |
| 1185 | }; |
| 1186 | let (mut engine, _handle) = Engine::new(config, &Config::default()); |
| 1187 | engine.session.approval_mode = approval_mode; |
| 1188 | |
| 1189 | let message = engine.user_text_message_with_turn_metadata("continue".to_string()); |
| 1190 | let ContentBlock::Text { text, .. } = message |
| 1191 | .content |
| 1192 | .last() |
| 1193 | .expect("turn metadata must be present") |
| 1194 | else { |
| 1195 | panic!("expected text turn metadata"); |
| 1196 | }; |
| 1197 | |
| 1198 | assert!( |
| 1199 | text.contains(&format!("Current permission posture: {posture}")), |
| 1200 | "{posture}: {text}" |
| 1201 | ); |
| 1202 | assert!( |
| 1203 | !text.contains("Current permission policy source"), |
| 1204 | "{posture}: doctrine must not re-enter turn_meta: {text}" |
| 1205 | ); |
| 1206 | assert!( |
| 1207 | !text.contains("Current question discipline"), |
| 1208 | "{posture}: question discipline must not re-enter turn_meta: {text}" |
| 1209 | ); |
| 1210 | assert_eq!( |
| 1211 | text.contains( |
| 1212 | "Approval prompts are disabled; do not request escalation for this turn." |
| 1213 | ), |
| 1214 | approval_mode == ApprovalMode::Never, |
| 1215 | "{posture}: {text}" |
| 1216 | ); |
| 1217 | } |
| 1218 | } |
| 1219 | |
| 1220 | #[test] |
| 1221 | fn turn_metadata_preserves_standing_full_access_for_subagent_handoff() { |
| 1222 | use ApprovalMode; |
| 1223 | |
| 1224 | let tmp = tempdir().expect("tempdir"); |
| 1225 | let config = EngineConfig { |
| 1226 | workspace: tmp.path().to_path_buf(), |
| 1227 | ..Default::default() |
| 1228 | }; |
| 1229 | let (mut engine, _handle) = Engine::new(config, &Config::default()); |
| 1230 | let authority = effective_input_policy( |
| 1231 | UserInputProvenance::SubAgentHandoff, |
| 1232 | AppMode::Agent, |
| 1233 | "continue from child", |
| 1234 | true, |
| 1235 | true, |
| 1236 | true, |
| 1237 | ApprovalMode::Bypass, |
| 1238 | ); |
| 1239 | engine.apply_runtime_mode_policy(&authority); |
| 1240 | |
| 1241 | let message = engine.runtime_text_message_with_turn_metadata( |
| 1242 | "continue from child".to_string(), |
| 1243 | UserInputProvenance::SubAgentHandoff, |
| 1244 | ); |
| 1245 | let ContentBlock::Text { text, .. } = message |
| 1246 | .content |
| 1247 | .last() |
| 1248 | .expect("turn metadata must be present") |
| 1249 | else { |
| 1250 | panic!("expected text turn metadata"); |
| 1251 | }; |
| 1252 | |
| 1253 | // A child handoff cannot grant new authority, but it retains the standing |
| 1254 | // posture and names its reduced provenance in one condensed line. |
| 1255 | assert!(!text.contains("Current mode:"), "{text}"); |
| 1256 | assert!( |
| 1257 | text.contains("Current permission posture: Full Access"), |
| 1258 | "{text}" |
| 1259 | ); |
| 1260 | assert!( |
| 1261 | text.contains("Input provenance: subagent_handoff (non-authoritative)"), |
| 1262 | "{text}" |
| 1263 | ); |
| 1264 | } |
| 1265 | |
| 1266 | #[test] |
| 1267 | fn current_mode_field_assignment_takes_effect_synchronously() { |
| 1268 | // Basic unit-level invariant: the current_mode field mutates as expected. |
| 1269 | // Op::ChangeMode dispatch through the run loop is exercised by the |
| 1270 | // integration test change_mode_op_updates_current_mode_and_emits_status. |
| 1271 | let tmp = tempdir().expect("tempdir"); |
| 1272 | let config = EngineConfig { |
| 1273 | workspace: tmp.path().to_path_buf(), |
| 1274 | model: "deepseek-v4-pro".to_string(), |
| 1275 | ..Default::default() |
| 1276 | }; |
| 1277 | let (mut engine, _handle) = Engine::new(config, &Config::default()); |
| 1278 | assert_eq!(engine.current_mode, AppMode::Agent); |
| 1279 | |
| 1280 | engine.current_mode = AppMode::Operate; |
| 1281 | assert_eq!(engine.current_mode, AppMode::Operate); |
| 1282 | } |
| 1283 | |
| 1284 | #[test] |
| 1285 | fn user_text_message_keeps_current_turn_input_after_turn_metadata() { |
| 1286 | let tmp = tempdir().expect("tempdir"); |
| 1287 | let config = EngineConfig { |
| 1288 | workspace: tmp.path().to_path_buf(), |
| 1289 | ..Default::default() |
| 1290 | }; |
| 1291 | let (engine, _handle) = Engine::new(config, &Config::default()); |
| 1292 | |
| 1293 | let user_msg = |
| 1294 | engine.user_text_message_with_turn_metadata("explain the cache metrics".to_string()); |
| 1295 | |
| 1296 | // User text is now at position 0, turn_meta at position 1. |
| 1297 | let first_text = user_msg |
| 1298 | .content |
| 1299 | .iter() |
| 1300 | .find_map(|block| { |
| 1301 | if let ContentBlock::Text { text, .. } = block { |
| 1302 | Some(text.as_str()) |
| 1303 | } else { |
| 1304 | None |
| 1305 | } |
| 1306 | }) |
| 1307 | .expect("user text block"); |
| 1308 | assert_eq!(first_text, "explain the cache metrics"); |
| 1309 | } |
| 1310 | |
| 1311 | #[test] |
| 1312 | fn messages_with_turn_metadata_preserves_stored_messages_for_prefix_cache() { |
| 1313 | let tmp = tempdir().expect("tempdir"); |
| 1314 | fs::create_dir_all(tmp.path().join("src")).expect("mkdir"); |
| 1315 | fs::write(tmp.path().join("src/lib.rs"), "pub fn sample() {}").expect("write"); |
| 1316 | |
| 1317 | let config = EngineConfig { |
| 1318 | workspace: tmp.path().to_path_buf(), |
| 1319 | ..Default::default() |
| 1320 | }; |
| 1321 | let (mut engine, _handle) = Engine::new(config, &Config::default()); |
| 1322 | engine |
| 1323 | .session |
| 1324 | .working_set |
| 1325 | .observe_user_message("inspect src/lib.rs", tmp.path()); |
| 1326 | |
| 1327 | let first_user = engine.user_text_message_with_turn_metadata("inspect src/lib.rs".to_string()); |
| 1328 | engine.session.add_message(first_user.clone()); |
| 1329 | let first_request = engine.messages_with_turn_metadata(); |
| 1330 | assert_eq!( |
| 1331 | &first_request[..engine.session.messages.len()], |
| 1332 | &engine.session.messages[..] |
| 1333 | ); |
| 1334 | assert_eq!(first_request.len(), engine.session.messages.len()); |
| 1335 | assert_eq!(first_request.first(), Some(&first_user)); |
| 1336 | assert_eq!( |
| 1337 | first_request.last().map(|message| message.role.as_str()), |
| 1338 | Some("user") |
| 1339 | ); |
| 1340 | |
| 1341 | engine.session.add_message(Message { |
| 1342 | role: Role::Assistant, |
| 1343 | content: vec![ContentBlock::Text { |
| 1344 | text: "I inspected it.".to_string(), |
| 1345 | cache_control: None, |
| 1346 | }], |
| 1347 | }); |
| 1348 | engine |
| 1349 | .session |
| 1350 | .working_set |
| 1351 | .observe_user_message("now summarize it", tmp.path()); |
| 1352 | let second_user = engine.user_text_message_with_turn_metadata("now summarize it".to_string()); |
| 1353 | engine.session.add_message(second_user); |
| 1354 | |
| 1355 | let second_request = engine.messages_with_turn_metadata(); |
| 1356 | assert_eq!( |
| 1357 | &second_request[..engine.session.messages.len()], |
| 1358 | &engine.session.messages[..] |
| 1359 | ); |
| 1360 | assert_eq!(second_request.len(), engine.session.messages.len()); |
| 1361 | assert_eq!(second_request.first(), Some(&first_user)); |
| 1362 | assert_eq!(second_request.last(), engine.session.messages.last()); |
| 1363 | } |
| 1364 | |
| 1365 | /// v0.8.11 regression: tool-result messages serialize to role="tool" on |
| 1366 | /// the wire but are stored as role="user" internally. `<turn_meta>` must |
| 1367 | /// be stored only on actual user-text messages. Request-time runtime metadata |
| 1368 | /// must not mutate tool-result messages. |
| 1369 | #[test] |
| 1370 | fn turn_metadata_skips_tool_result_messages() { |
| 1371 | let tmp = tempdir().expect("tempdir"); |
| 1372 | fs::create_dir_all(tmp.path().join("src")).expect("mkdir"); |
| 1373 | fs::write(tmp.path().join("src/lib.rs"), "pub fn sample() {}").expect("write"); |
| 1374 | |
| 1375 | let config = EngineConfig { |
| 1376 | workspace: tmp.path().to_path_buf(), |
| 1377 | ..Default::default() |
| 1378 | }; |
| 1379 | let (mut engine, _handle) = Engine::new(config, &Config::default()); |
| 1380 | engine |
| 1381 | .session |
| 1382 | .working_set |
| 1383 | .observe_user_message("inspect src/lib.rs", tmp.path()); |
| 1384 | |
| 1385 | // Real user message — should be eligible for injection. |
| 1386 | let user_msg = engine.user_text_message_with_turn_metadata("inspect src/lib.rs".to_string()); |
| 1387 | engine.session.add_message(user_msg); |
| 1388 | // Assistant tool-call. |
| 1389 | engine.session.add_message(Message { |
| 1390 | role: Role::Assistant, |
| 1391 | content: vec![ContentBlock::ToolUse { |
| 1392 | execution_id: None, |
| 1393 | id: "call_42".to_string(), |
| 1394 | name: "read_file".to_string(), |
| 1395 | input: serde_json::json!({"path": "src/lib.rs"}), |
| 1396 | caller: None, |
| 1397 | thought_signature: None, |
| 1398 | }], |
| 1399 | }); |
| 1400 | // Tool result, stored as role="user" internally. |
| 1401 | engine.session.add_message(Message { |
| 1402 | role: Role::User, |
| 1403 | content: vec![ContentBlock::ToolResult { |
| 1404 | execution_id: None, |
| 1405 | tool_use_id: "call_42".to_string(), |
| 1406 | content: "pub fn sample() {}".to_string(), |
| 1407 | is_error: None, |
| 1408 | content_blocks: None, |
| 1409 | }], |
| 1410 | }); |
| 1411 | |
| 1412 | let messages = engine.messages_with_turn_metadata(); |
| 1413 | |
| 1414 | // The stored trailing message is the tool result and MUST be untouched — |
| 1415 | // no Text block sneaking in front of the ToolResult block. |
| 1416 | let trailing = messages.last().expect("stored trailing message"); |
| 1417 | assert_eq!(trailing.role, "user"); |
| 1418 | assert_eq!(trailing.content.len(), 1); |
| 1419 | assert!(matches!( |
| 1420 | trailing.content.first(), |
| 1421 | Some(ContentBlock::ToolResult { .. }) |
| 1422 | )); |
| 1423 | |
| 1424 | // The earlier real user message carries user text first, turn_meta last. |
| 1425 | let real_user = messages.first().expect("first user message"); |
| 1426 | assert_eq!(real_user.role, "user"); |
| 1427 | let ContentBlock::Text { text, .. } = real_user.content.first().expect("user text content") |
| 1428 | else { |
| 1429 | panic!("expected Text block on real user message"); |
| 1430 | }; |
| 1431 | assert_eq!(text, "inspect src/lib.rs"); |
| 1432 | // turn_meta is at the tail of the content array. |
| 1433 | let last_block = real_user.content.last().expect("turn_meta block"); |
| 1434 | let ContentBlock::Text { text: meta, .. } = last_block else { |
| 1435 | panic!("expected Text block for turn_meta at tail"); |
| 1436 | }; |
| 1437 | assert!(meta.starts_with("<turn_meta>\n")); |
| 1438 | assert!(meta.contains("src/lib.rs")); |
| 1439 | } |
| 1440 | |
| 1441 | /// User text must appear before turn_meta in the content array so that |
| 1442 | /// the leading bytes of each user message stay stable across date changes. |
| 1443 | /// DeepSeek's KV prefix cache matches byte sequences from the start of |
| 1444 | /// each message; placing the volatile date-bearing turn_meta at position |
| 1445 | /// 0 would invalidate the entire user message prefix at every date |
| 1446 | /// boundary. Moving it to the tail preserves the user-input prefix. |
| 1447 | #[test] |
| 1448 | fn user_message_turn_meta_is_appended_not_prepended() { |
| 1449 | let tmp = tempdir().expect("tempdir"); |
| 1450 | let config = EngineConfig { |
| 1451 | workspace: tmp.path().to_path_buf(), |
| 1452 | ..Default::default() |
| 1453 | }; |
| 1454 | let (engine, _handle) = Engine::new(config, &Config::default()); |
| 1455 | |
| 1456 | let msg = engine.user_text_message_with_turn_metadata("hello world".to_string()); |
| 1457 | assert_eq!(msg.role, "user"); |
| 1458 | assert_eq!(msg.content.len(), 2); |
| 1459 | |
| 1460 | // First content block: user text. |
| 1461 | let ContentBlock::Text { text, .. } = &msg.content[0] else { |
| 1462 | panic!("expected Text block at position 0"); |
| 1463 | }; |
| 1464 | assert_eq!(text, "hello world"); |
| 1465 | |
| 1466 | // Second content block: turn_meta. |
| 1467 | let ContentBlock::Text { text: meta, .. } = &msg.content[1] else { |
| 1468 | panic!("expected Text block for turn_meta at position 1"); |
| 1469 | }; |
| 1470 | assert!( |
| 1471 | meta.starts_with("<turn_meta>\n"), |
| 1472 | "turn_meta must be at the tail" |
| 1473 | ); |
| 1474 | assert!( |
| 1475 | meta.contains("Current local date:"), |
| 1476 | "turn_meta must contain the date" |
| 1477 | ); |
| 1478 | } |
| 1479 | |
| 1480 | /// When the turn is mid-execution and the trailing user message is a |
| 1481 | /// tool result, no turn_meta is injected into that tool-result message. The |
| 1482 | /// working_set surfaces again on the next stored user-text message. |
| 1483 | #[test] |
| 1484 | fn turn_metadata_skips_when_only_tool_results_trail() { |
| 1485 | let tmp = tempdir().expect("tempdir"); |
| 1486 | fs::create_dir_all(tmp.path().join("src")).expect("mkdir"); |
| 1487 | fs::write(tmp.path().join("src/lib.rs"), "pub fn sample() {}").expect("write"); |
| 1488 | |
| 1489 | let config = EngineConfig { |
| 1490 | workspace: tmp.path().to_path_buf(), |
| 1491 | ..Default::default() |
| 1492 | }; |
| 1493 | let (mut engine, _handle) = Engine::new(config, &Config::default()); |
| 1494 | engine |
| 1495 | .session |
| 1496 | .working_set |
| 1497 | .observe_user_message("inspect src/lib.rs", tmp.path()); |
| 1498 | |
| 1499 | // Only a tool-result message in history — simulates the corner case |
| 1500 | // where the prior real user message has already been compacted away |
| 1501 | // but a tool-result is still pending. We must not retroactively |
| 1502 | // inject. |
| 1503 | engine.session.add_message(Message { |
| 1504 | role: Role::User, |
| 1505 | content: vec![ContentBlock::ToolResult { |
| 1506 | execution_id: None, |
| 1507 | tool_use_id: "call_42".to_string(), |
| 1508 | content: "pub fn sample() {}".to_string(), |
| 1509 | is_error: None, |
| 1510 | content_blocks: None, |
| 1511 | }], |
| 1512 | }); |
| 1513 | |
| 1514 | let messages = engine.messages_with_turn_metadata(); |
| 1515 | |
| 1516 | // Stored tool-result message is unchanged: no Text prefix, content length == 1. |
| 1517 | let only = messages.first().expect("stored tool result message"); |
| 1518 | assert_eq!(only.content.len(), 1); |
| 1519 | assert!(matches!( |
| 1520 | only.content.first(), |
| 1521 | Some(ContentBlock::ToolResult { .. }) |
| 1522 | )); |
| 1523 | assert_eq!(messages.len(), 1); |
| 1524 | } |
| 1525 | |
| 1526 | #[test] |
| 1527 | fn declared_refresh_sets_pending_prefix_change_reason() { |
| 1528 | let _lock = lock_test_env(); |
| 1529 | let tmp = tempdir().expect("tempdir"); |
| 1530 | let config = EngineConfig { |
| 1531 | workspace: tmp.path().to_path_buf(), |
| 1532 | ..Default::default() |
| 1533 | }; |
| 1534 | let (mut engine, _handle) = Engine::new(config, &Config::default()); |
| 1535 | // Construction pins the initial prompt; clear any construction-time flag. |
| 1536 | engine.session.pending_prefix_change_reason = None; |
| 1537 | |
| 1538 | // A no-op refresh (unchanged bytes) declares nothing. |
| 1539 | engine.refresh_system_prompt_with_reason("system"); |
| 1540 | assert_eq!(engine.session.pending_prefix_change_reason, None); |
| 1541 | |
| 1542 | // A refresh that actually changes the bytes records the declared reason. |
| 1543 | engine.config.goal_objective = Some("ship the release".to_string()); |
| 1544 | engine.config.goal_status = crate::tools::goal::GoalStatus::Active; |
| 1545 | engine.refresh_system_prompt_with_reason("goal"); |
| 1546 | assert_eq!( |
| 1547 | engine.session.pending_prefix_change_reason.as_deref(), |
| 1548 | Some("goal") |
| 1549 | ); |
| 1550 | } |
| 1551 | |
| 1552 | #[test] |
| 1553 | fn workspace_file_change_never_moves_the_frozen_prefix() { |
| 1554 | // The old bug: the tool loop recomposed the system prompt from disk on |
| 1555 | // every step, so an agent writing a file changed the project pack and |
| 1556 | // busted DeepSeek's KV prefix cache mid-turn. The header is now frozen |
| 1557 | // for the session: only an explicit refresh (a declared header change) |
| 1558 | // recomposes it, and the tool loop no longer calls one. |
| 1559 | let _lock = lock_test_env(); |
| 1560 | let tmp = tempdir().expect("tempdir"); |
| 1561 | let config = EngineConfig { |
| 1562 | workspace: tmp.path().to_path_buf(), |
| 1563 | project_context_pack_enabled: true, |
| 1564 | ..Default::default() |
| 1565 | }; |
| 1566 | let (mut engine, _handle) = Engine::new(config, &Config::default()); |
| 1567 | let frozen_prompt = engine.session.system_prompt.clone(); |
| 1568 | engine.session.pending_prefix_change_reason = None; |
| 1569 | |
| 1570 | // Simulate the agent writing a file into the workspace mid-turn. |
| 1571 | fs::write(tmp.path().join("NEWFILE.md"), "brand new content").expect("write"); |
| 1572 | |
| 1573 | // What a fresh compose WOULD produce now differs — the bug precondition. |
| 1574 | let recomposed = |
| 1575 | engine.compose_stable_system_prompt(&engine.installed_next_turn_prompt_context()); |
| 1576 | assert_ne!( |
| 1577 | recomposed, frozen_prompt, |
| 1578 | "a workspace file change must change what a fresh compose would produce" |
| 1579 | ); |
| 1580 | |
| 1581 | // But the session's pinned prompt is untouched and nothing was declared, |
| 1582 | // because the tool loop performs no mid-loop refresh. |
| 1583 | assert_eq!(engine.session.system_prompt, frozen_prompt); |
| 1584 | assert_eq!(engine.session.pending_prefix_change_reason, None); |
| 1585 | } |