返回 CodeWhale
test_cases_15.rs
根目录 / crates / tui / src / core / engine / tests / test_cases_15.rs
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 }
1585 lines RUST