返回 CodeWhale
debug_mutation_host_tests.rs
根目录 / crates / tui / src / commands / debug_mutation_host_tests.rs
1 //! Preserved snapshot/history safety tests outside the portable debug group.
2 use super::CommandResult;
3 use super::contract::debug_operations::prune_undone_tool_context;
4 use super::groups::debug::undo;
5 fn patch_undo(app: &mut App) -> CommandResult {
6 super::debug_group::host_result(undo::patch_result(
7 super::contract::debug_operations::undo_files(app),
8 ))
9 }
10 fn undo_conversation(app: &mut App) -> CommandResult {
11 super::debug_group::host_result(undo::conversation_result(
12 super::contract::debug_operations::undo_conversation_for_engine(app),
13 ))
14 }
15 fn retry(app: &mut App) -> CommandResult {
16 super::execute("/retry", app)
17 }
18 use crate::config::Config;
19 use crate::tui::app::{App, AppAction, TuiOptions};
20 use crate::tui::history::{GenericToolCell, HistoryCell, ToolCell, ToolStatus};
21 use codewhale_models::Role;
22 use codewhale_models::{ContentBlock, Message, Tool};
23 use std::path::PathBuf;
24
25 pub(in crate::commands) fn create_test_app() -> App {
26 let options = TuiOptions {
27 skills_dir: PathBuf::from("/tmp/test-skills"),
28 ..crate::test_support::test_tui_options(PathBuf::from("/tmp/test-workspace"))
29 };
30 let mut app = App::new(options, &Config::default());
31 app.ui_locale = codewhale_localization::Locale::En;
32 app.cost_currency = crate::pricing::CostCurrency::Usd;
33 app.api_provider = crate::config::ProviderKind::Deepseek;
34 app
35 }
36
37 #[test]
38 fn edit_dispatch_loads_unicode_composer_without_truncating_history() {
39 let mut app = create_test_app();
40 app.push_history_cell(HistoryCell::User {
41 content: "edit 漢字🙂".into(),
42 });
43 let count = app.history.len();
44 let result = super::execute("/edit", &mut app);
45 assert_eq!(
46 result.message.as_deref(),
47 Some("Last message loaded into composer — edit and press Enter to resubmit")
48 );
49 assert_eq!(app.input, "edit 漢字🙂");
50 assert_eq!(app.cursor_position, "edit 漢字🙂".chars().count());
51 assert!(app.edit_in_progress);
52 assert_eq!(app.history.len(), count);
53 assert!(result.action.is_none());
54 assert!(!result.is_error);
55 }
56
57 /// A queued follow-up open for editing must not be overwritten or later sent
58 /// in place of the `/edit` revision: it goes back to the queue, text intact.
59 #[test]
60 fn edit_dispatch_returns_an_open_queued_draft_to_the_queue() {
61 use crate::tui::app::QueuedMessage;
62 let mut app = create_test_app();
63 app.push_history_cell(HistoryCell::User {
64 content: "last sent".into(),
65 });
66 app.queued_messages
67 .push_back(QueuedMessage::new("queued follow-up".to_string(), None));
68 assert!(app.pop_last_queued_into_draft());
69 assert_eq!(app.input, "queued follow-up");
70 assert!(app.queued_draft.is_some());
71
72 let result = super::execute("/edit", &mut app);
73 assert!(!result.is_error, "{:?}", result.message);
74 assert!(app.queued_draft.is_none(), "draft edit is closed");
75 assert_eq!(
76 app.queued_messages
77 .iter()
78 .map(|message| message.display.as_str())
79 .collect::<Vec<_>>(),
80 ["queued follow-up"],
81 "the queued follow-up is back in the queue, exactly once"
82 );
83 assert_eq!(app.input, "last sent");
84 assert!(app.edit_in_progress);
85 }
86
87 #[test]
88 fn diff_dispatch_reads_only_the_apps_workspace_without_changing_files() {
89 let workspace = tempfile::tempdir().unwrap();
90 let git = |args: &[&str]| {
91 let result = std::process::Command::new("git")
92 .args(args)
93 .current_dir(workspace.path())
94 .output()
95 .unwrap();
96 assert!(
97 result.status.success(),
98 "{}",
99 String::from_utf8_lossy(&result.stderr)
100 );
101 String::from_utf8(result.stdout).unwrap()
102 };
103 git(&["init", "--quiet"]);
104 std::fs::write(workspace.path().join("tracked.txt"), "before\n").unwrap();
105 git(&["add", "tracked.txt"]);
106 git(&[
107 "-c",
108 "user.name=Fixture",
109 "-c",
110 "user.email=fixture@example.invalid",
111 "-c",
112 "commit.gpgsign=false",
113 "commit",
114 "--quiet",
115 "-m",
116 "fixture",
117 ]);
118 let mut app = create_test_app();
119 app.workspace = workspace.path().to_path_buf();
120 assert_eq!(
121 super::execute("/diff", &mut app).message.as_deref(),
122 Some("No changes since session start")
123 );
124 std::fs::write(workspace.path().join("tracked.txt"), "after\n").unwrap();
125 let stat = git(&["diff", "--stat"]);
126 let result = super::execute("/diff", &mut app);
127 assert_eq!(
128 result.message,
129 Some(format!(
130 "Changed files (1):\ntracked.txt\n\n── Stat ──\n{}",
131 stat.trim()
132 ))
133 );
134 assert!(!result.is_error);
135 assert!(result.action.is_none());
136 assert_eq!(
137 std::fs::read_to_string(workspace.path().join("tracked.txt")).unwrap(),
138 "after\n"
139 );
140 }
141
142 pub(in crate::commands) fn test_tool(name: &str) -> Tool {
143 Tool {
144 tool_type: Some("function".to_string()),
145 name: name.to_string(),
146 description: format!("{name} test tool"),
147 input_schema: serde_json::json!({
148 "type": "object",
149 "properties": {
150 "path": {"type": "string"}
151 }
152 }),
153 allowed_callers: None,
154 defer_loading: Some(false),
155 input_examples: None,
156 strict: Some(true),
157 cache_control: None,
158 }
159 }
160
161 #[test]
162 fn test_undo_conversation_stages_last_exchange_without_mutating_live_history() {
163 let mut app = create_test_app();
164 app.history.push(HistoryCell::User {
165 content: "Hello".to_string(),
166 });
167 app.history.push(HistoryCell::Assistant {
168 content: "Hi".to_string(),
169 streaming: false,
170 });
171 app.api_messages_mut().push(Message {
172 role: Role::User,
173 content: vec![],
174 });
175 app.api_messages_mut().push(Message {
176 role: Role::Assistant,
177 content: vec![],
178 });
179
180 let initial_history_len = app.history.len();
181 let initial_api_len = app.api_messages.len();
182 let result = undo_conversation(&mut app);
183
184 assert!(result.message.is_some());
185 let msg = result.message.unwrap();
186 assert!(msg.contains("Removed"));
187 assert_eq!(
188 app.history.len(),
189 initial_history_len,
190 "planning leaves live UI untouched"
191 );
192 assert_eq!(app.api_messages.len(), initial_api_len);
193 assert!(
194 matches!(result.action, Some(AppAction::ConversationUndo { sync, retry_input: None, .. }) if sync.messages.is_empty())
195 );
196 }
197
198 #[test]
199 fn conversation_undo_includes_following_tool_results_and_runtime_notes() {
200 let mut app = create_test_app();
201 app.history.push(HistoryCell::User {
202 content: "undo this".into(),
203 });
204 let messages: Vec<Message> = serde_json::from_value(serde_json::json!([
205 {"role":"user","content":[{"type":"text","text":"keep this"}]},
206 {"role":"assistant","content":[{"type":"text","text":"kept answer"}]},
207 {"role":"user","content":[{"type":"text","text":"undo this"}]},
208 {"role":"assistant","content":[{"type":"tool_use","id":"call-1","name":"read_file","input":{}}]},
209 {"role":"user","content":[{"type":"tool_result","tool_use_id":"call-1","content":"undone tool result"}]},
210 {"role":"assistant","content":[{"type":"text","text":"undone answer"}]}
211 ])).unwrap();
212 app.set_api_messages(std::sync::Arc::new(messages.clone()));
213 let result = undo_conversation(&mut app);
214 let Some(AppAction::ConversationUndo { sync, .. }) = result.action else {
215 panic!("missing rollback")
216 };
217 assert_eq!(sync.messages, messages[..2]);
218 assert_eq!(app.api_messages.as_ref(), &messages);
219 }
220
221 #[test]
222 fn test_undo_conversation_nothing_to_undo() {
223 let mut app = create_test_app();
224 // Clear any default history
225 app.history.clear();
226 app.api_messages_mut().clear();
227 let result = undo_conversation(&mut app);
228 assert!(result.message.is_some());
229 let msg = result.message.unwrap();
230 assert!(msg.contains("Nothing to undo") || msg.contains("Removed"));
231 }
232
233 #[test]
234 fn test_retry_with_previous_message() {
235 let mut app = create_test_app();
236 app.history.push(HistoryCell::User {
237 content: "Test message".to_string(),
238 });
239 app.history.push(HistoryCell::Assistant {
240 content: "Response".to_string(),
241 streaming: false,
242 });
243
244 let result = retry(&mut app);
245 assert!(result.message.is_some());
246 let msg = result.message.unwrap();
247 assert!(msg.contains("Retrying"));
248 assert!(msg.contains("Test message"));
249 assert!(matches!(
250 result.action.as_ref(),
251 Some(AppAction::ConversationUndo { retry_input: Some(input), .. }) if input == "Test message"
252 ));
253 }
254
255 #[test]
256 fn test_retry_no_previous_message() {
257 let mut app = create_test_app();
258 let result = retry(&mut app);
259 assert!(result.message.is_some());
260 let msg = result.message.unwrap();
261 assert!(msg.contains("No previous request to retry"));
262 assert!(result.action.is_none());
263 }
264
265 #[test]
266 fn test_retry_truncates_long_input() {
267 let mut app = create_test_app();
268 let long_input = "x".repeat(100);
269 app.history.push(HistoryCell::User {
270 content: long_input.clone(),
271 });
272 app.history.push(HistoryCell::Assistant {
273 content: "Response".to_string(),
274 streaming: false,
275 });
276
277 let result = retry(&mut app);
278 assert!(result.message.is_some());
279 let msg = result.message.unwrap();
280 assert!(msg.contains("Retrying"));
281 assert!(msg.contains("..."));
282 }
283
284 #[test]
285 fn test_patch_undo_requests_session_resync_after_restore() {
286 use crate::snapshot::SnapshotRepo;
287 use tempfile::tempdir;
288
289 let tmp = tempdir().unwrap();
290 let workspace = tmp.path().join("ws");
291 std::fs::create_dir_all(&workspace).unwrap();
292 let _guard = crate::test_support::SealedHome::at(tmp.path());
293
294 let repo = SnapshotRepo::open_or_init(&workspace).unwrap();
295 std::fs::write(workspace.join("a.txt"), b"original").unwrap();
296 repo.snapshot_with_session("pre-turn:1", Some("test-session"))
297 .unwrap();
298 std::fs::write(workspace.join("a.txt"), b"modified").unwrap();
299 repo.snapshot_with_session("post-turn:1", Some("test-session"))
300 .unwrap();
301
302 let mut app = create_test_app();
303 app.workspace = workspace.clone();
304 app.yolo = true;
305 app.current_session_id = Some("test-session".to_string());
306 app.api_messages_mut().push(Message {
307 role: Role::User,
308 content: vec![ContentBlock::Text {
309 text: "please edit a.txt".to_string(),
310 cache_control: None,
311 }],
312 });
313
314 // A running turn owns the workspace: nothing is restored under it.
315 app.is_loading = true;
316 let refused = patch_undo(&mut app);
317 assert!(
318 refused
319 .message
320 .as_deref()
321 .is_some_and(|message| message.contains("still running")),
322 "{:?}",
323 refused.message
324 );
325 assert!(refused.action.is_none());
326 assert_eq!(
327 std::fs::read(workspace.join("a.txt")).unwrap(),
328 b"modified",
329 "a refused undo changes no file"
330 );
331 app.is_loading = false;
332
333 let result = patch_undo(&mut app);
334 assert_eq!(std::fs::read(workspace.join("a.txt")).unwrap(), b"original");
335
336 assert!(!result.is_error);
337 assert!(matches!(
338 result.action,
339 Some(AppAction::SyncSession {
340 ref messages,
341 ref workspace,
342 ..
343 }) if messages.as_slice() == app.api_messages.as_slice()
344 && workspace == &app.workspace
345 ));
346 }
347
348 #[test]
349 fn test_undo_legacy_chain_falls_back_to_conversation_only() {
350 use crate::snapshot::SnapshotRepo;
351 use tempfile::tempdir;
352
353 let tmp = tempdir().unwrap();
354 let workspace = tmp.path().join("ws");
355 std::fs::create_dir_all(&workspace).unwrap();
356 let _guard = crate::test_support::SealedHome::at(tmp.path());
357
358 let repo = SnapshotRepo::open_or_init(&workspace).unwrap();
359 let file = workspace.join("a.txt");
360 std::fs::write(&file, b"zero").unwrap();
361 repo.snapshot("tool:first").unwrap();
362 std::fs::write(&file, b"one").unwrap();
363 repo.snapshot("tool:second").unwrap();
364 std::fs::write(&file, b"two").unwrap();
365
366 let mut app = create_test_app();
367 app.workspace = workspace.clone();
368 app.current_session_id = Some("current-session".to_string());
369 app.history.push(HistoryCell::User {
370 content: "chat only".to_string(),
371 });
372 app.history.push(HistoryCell::Assistant {
373 content: "reply".to_string(),
374 streaming: false,
375 });
376
377 let result = super::execute("/undo", &mut app);
378 assert!(!result.is_error);
379 assert!(
380 result
381 .message
382 .as_deref()
383 .is_some_and(|m| m.contains("Removed")),
384 "expected conversation fallback, got: {:?}",
385 result.message
386 );
387 assert_eq!(std::fs::read_to_string(&file).unwrap(), "two");
388 }
389
390 #[test]
391 fn test_patch_undo_prunes_tool_turn_context() {
392 use crate::snapshot::SnapshotRepo;
393 use tempfile::tempdir;
394
395 let tmp = tempdir().unwrap();
396 let workspace = tmp.path().join("ws");
397 std::fs::create_dir_all(&workspace).unwrap();
398 let _guard = crate::test_support::SealedHome::at(tmp.path());
399
400 let repo = SnapshotRepo::open_or_init(&workspace).unwrap();
401 let file = workspace.join("a.txt");
402 std::fs::write(&file, b"alpha").unwrap();
403 repo.snapshot_with_session("tool:call-1", Some("test-session"))
404 .unwrap();
405 std::fs::write(&file, b"alpha-fixed").unwrap();
406
407 let mut app = create_test_app();
408 app.workspace = workspace.clone();
409 app.yolo = true;
410 app.current_session_id = Some("test-session".to_string());
411 app.history.push(HistoryCell::User {
412 content: "please edit a.txt".to_string(),
413 });
414 app.history.push(HistoryCell::Assistant {
415 content: "I will update the file.".to_string(),
416 streaming: false,
417 });
418 app.history
419 .push(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
420 name: "write_file".to_string(),
421 status: ToolStatus::Success,
422 input_summary: Some("a.txt".to_string()),
423 output: Some("updated".to_string()),
424 prompts: None,
425 spillover_path: None,
426 output_summary: None,
427 is_diff: false,
428 })));
429 app.history.push(HistoryCell::Assistant {
430 content: "Done, file is fixed now.".to_string(),
431 streaming: false,
432 });
433 app.tool_cells.insert("call-1".to_string(), 2);
434
435 app.api_messages_mut().push(Message {
436 role: Role::User,
437 content: vec![ContentBlock::Text {
438 text: "please edit a.txt".to_string(),
439 cache_control: None,
440 }],
441 });
442 app.api_messages_mut().push(Message {
443 role: Role::Assistant,
444 content: vec![
445 ContentBlock::Text {
446 text: "I will update the file.".to_string(),
447 cache_control: None,
448 },
449 ContentBlock::ToolUse {
450 execution_id: None,
451 id: "call-1".to_string(),
452 name: "write_file".to_string(),
453 input: serde_json::json!({"path": "a.txt"}),
454 caller: None,
455 thought_signature: None,
456 },
457 ],
458 });
459 app.api_messages_mut().push(Message {
460 role: Role::User,
461 content: vec![ContentBlock::ToolResult {
462 execution_id: None,
463 tool_use_id: "call-1".to_string(),
464 content: "updated".to_string(),
465 is_error: None,
466 content_blocks: None,
467 }],
468 });
469 app.api_messages_mut().push(Message {
470 role: Role::Assistant,
471 content: vec![ContentBlock::Text {
472 text: "Done, file is fixed now.".to_string(),
473 cache_control: None,
474 }],
475 });
476
477 let result = patch_undo(&mut app);
478
479 assert!(!result.is_error);
480 assert_eq!(std::fs::read_to_string(&file).unwrap(), "alpha");
481 assert_eq!(app.history.len(), 3);
482 assert!(matches!(
483 app.history.last(),
484 Some(HistoryCell::System { content }) if content.contains("/undo reverted workspace")
485 ));
486 assert_eq!(app.api_messages.len(), 2);
487 assert!(matches!(
488 &app.api_messages[0].content[0],
489 ContentBlock::Text { text, .. } if text == "please edit a.txt"
490 ));
491 assert_eq!(app.api_messages[1].content.len(), 1);
492 assert!(matches!(
493 &app.api_messages[1].content[0],
494 ContentBlock::Text { text, .. } if text == "I will update the file."
495 ));
496 }
497
498 #[test]
499 fn test_patch_undo_prunes_pre_turn_context() {
500 use crate::snapshot::SnapshotRepo;
501 use tempfile::tempdir;
502
503 let tmp = tempdir().unwrap();
504 let workspace = tmp.path().join("ws");
505 std::fs::create_dir_all(&workspace).unwrap();
506 let _guard = crate::test_support::SealedHome::at(tmp.path());
507
508 let repo = SnapshotRepo::open_or_init(&workspace).unwrap();
509 let file = workspace.join("a.txt");
510 std::fs::write(&file, b"alpha").unwrap();
511 repo.snapshot_with_session("pre-turn:1", Some("test-session"))
512 .unwrap();
513 std::fs::write(&file, b"alpha-fixed").unwrap();
514
515 let mut app = create_test_app();
516 app.workspace = workspace.clone();
517 app.yolo = true;
518 app.current_session_id = Some("test-session".to_string());
519 app.history.push(HistoryCell::User {
520 content: "please edit a.txt".to_string(),
521 });
522 app.history.push(HistoryCell::Assistant {
523 content: "Done, file is fixed now.".to_string(),
524 streaming: false,
525 });
526 app.api_messages_mut().push(Message {
527 role: Role::User,
528 content: vec![ContentBlock::Text {
529 text: "please edit a.txt".to_string(),
530 cache_control: None,
531 }],
532 });
533 app.api_messages_mut().push(Message {
534 role: Role::Assistant,
535 content: vec![ContentBlock::Text {
536 text: "Done, file is fixed now.".to_string(),
537 cache_control: None,
538 }],
539 });
540
541 let result = patch_undo(&mut app);
542
543 assert!(!result.is_error);
544 assert_eq!(std::fs::read_to_string(&file).unwrap(), "alpha");
545 assert_eq!(app.history.len(), 1);
546 assert!(matches!(
547 app.history.last(),
548 Some(HistoryCell::System { content }) if content.contains("/undo reverted workspace")
549 ));
550 assert!(app.api_messages.is_empty());
551 }
552
553 #[test]
554 fn test_prune_undone_tool_context_preserves_prior_tool_pairs() {
555 let mut app = create_test_app();
556 app.history.push(HistoryCell::User {
557 content: "edit two files".to_string(),
558 });
559 app.history.push(HistoryCell::Assistant {
560 content: "I will update both files.".to_string(),
561 streaming: false,
562 });
563 app.history
564 .push(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
565 name: "write_file".to_string(),
566 status: ToolStatus::Success,
567 input_summary: Some("a.txt".to_string()),
568 output: Some("updated a".to_string()),
569 prompts: None,
570 spillover_path: None,
571 output_summary: None,
572 is_diff: false,
573 })));
574 app.history
575 .push(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
576 name: "write_file".to_string(),
577 status: ToolStatus::Success,
578 input_summary: Some("b.txt".to_string()),
579 output: Some("updated b".to_string()),
580 prompts: None,
581 spillover_path: None,
582 output_summary: None,
583 is_diff: false,
584 })));
585 app.history.push(HistoryCell::Assistant {
586 content: "Done.".to_string(),
587 streaming: false,
588 });
589 app.tool_cells.insert("call-a".to_string(), 2);
590 app.tool_cells.insert("call-b".to_string(), 3);
591
592 app.api_messages_mut().push(Message {
593 role: Role::User,
594 content: vec![ContentBlock::Text {
595 text: "edit two files".to_string(),
596 cache_control: None,
597 }],
598 });
599 app.api_messages_mut().push(Message {
600 role: Role::Assistant,
601 content: vec![
602 ContentBlock::Text {
603 text: "I will update both files.".to_string(),
604 cache_control: None,
605 },
606 ContentBlock::ToolUse {
607 execution_id: None,
608 id: "call-a".to_string(),
609 name: "write_file".to_string(),
610 input: serde_json::json!({"path": "a.txt"}),
611 caller: None,
612 thought_signature: None,
613 },
614 ContentBlock::ToolUse {
615 execution_id: None,
616 id: "call-b".to_string(),
617 name: "write_file".to_string(),
618 input: serde_json::json!({"path": "b.txt"}),
619 caller: None,
620 thought_signature: None,
621 },
622 ],
623 });
624 app.api_messages_mut().push(Message {
625 role: Role::User,
626 content: vec![ContentBlock::ToolResult {
627 execution_id: None,
628 tool_use_id: "call-a".to_string(),
629 content: "updated a".to_string(),
630 is_error: None,
631 content_blocks: None,
632 }],
633 });
634 app.api_messages_mut().push(Message {
635 role: Role::User,
636 content: vec![ContentBlock::ToolResult {
637 execution_id: None,
638 tool_use_id: "call-b".to_string(),
639 content: "updated b".to_string(),
640 is_error: None,
641 content_blocks: None,
642 }],
643 });
644 app.api_messages_mut().push(Message {
645 role: Role::Assistant,
646 content: vec![ContentBlock::Text {
647 text: "Done.".to_string(),
648 cache_control: None,
649 }],
650 });
651
652 prune_undone_tool_context(&mut app, "call-b");
653
654 assert_eq!(app.history.len(), 3);
655 assert_eq!(app.api_messages.len(), 3);
656 assert!(matches!(
657 &app.api_messages[1].content[..],
658 [
659 ContentBlock::Text { .. },
660 ContentBlock::ToolUse { id, ..}
661 ] if id == "call-a"
662 ));
663 assert!(matches!(
664 &app.api_messages[2].content[0],
665 ContentBlock::ToolResult { tool_use_id, .. } if tool_use_id == "call-a"
666 ));
667 }
668
669 #[test]
670 fn undo_uses_execution_identity_and_preserves_coalesced_result_stamp() {
671 let mut app = create_test_app();
672 let stamp = chrono::DateTime::parse_from_rfc3339("2026-01-02T03:04:05Z")
673 .unwrap()
674 .with_timezone(&chrono::Utc);
675 let messages: Vec<Message> = serde_json::from_value(serde_json::json!([
676 {"role":"assistant","content":[
677 {"type":"tool_use","id":"wire","execution_id":"first","name":"write_file","input":{"path":"a.txt"}},
678 {"type":"tool_use","id":"wire","execution_id":"second","name":"write_file","input":{"path":"b.txt"}}
679 ]},
680 {"role":"user","content":[
681 {"type":"tool_result","tool_use_id":"wire","execution_id":"first","content":"kept"},
682 {"type":"tool_result","tool_use_id":"wire","execution_id":"second","content":"undone"}
683 ]}
684 ])).unwrap();
685 for message in messages {
686 app.push_api_message_stamped(message, stamp);
687 }
688 prune_undone_tool_context(&mut app, "second");
689 assert_eq!(app.api_messages.len(), 2);
690 assert_eq!(app.api_messages[0].content.len(), 1);
691 assert!(
692 matches!(&app.api_messages[1].content[..], [ContentBlock::ToolResult {
693 execution_id: Some(id), tool_use_id, content, ..
694 }] if id == "first" && tool_use_id == "wire" && content == "kept")
695 );
696 assert_eq!(app.api_messages_stamped().nth(1).unwrap().1, stamp);
697
698 // A legacy provider ID that happens to spell a local ID is not another
699 // spelling for that execution. With both present, the raw undo request is
700 // ambiguous and must leave every message intact.
701 app.push_api_message_stamped(
702 serde_json::from_value(serde_json::json!({
703 "role":"assistant","content":[
704 {"type":"tool_use","id":"first","name":"write_file","input":{}}
705 ]
706 }))
707 .unwrap(),
708 stamp,
709 );
710 let before = serde_json::to_value(&*app.api_messages).unwrap();
711 prune_undone_tool_context(&mut app, "first");
712 assert_eq!(serde_json::to_value(&*app.api_messages).unwrap(), before);
713 }
714
715 // ── /cache stats tests ──────────────────────────────────────────────
716
717 #[test]
718 fn test_patch_undo_refuses_outside_trusted_mode() {
719 use crate::snapshot::SnapshotRepo;
720 use tempfile::tempdir;
721
722 let tmp = tempdir().unwrap();
723 let workspace = tmp.path().join("ws");
724 std::fs::create_dir_all(&workspace).unwrap();
725 let _guard = crate::test_support::SealedHome::at(tmp.path());
726
727 let repo = SnapshotRepo::open_or_init(&workspace).unwrap();
728 std::fs::write(workspace.join("a.txt"), b"original").unwrap();
729 repo.snapshot_with_session("pre-turn:1", Some("test-session"))
730 .unwrap();
731 std::fs::write(workspace.join("a.txt"), b"modified").unwrap();
732
733 // yolo/trust_mode stay false (create_test_app defaults).
734 let mut app = create_test_app();
735 app.workspace = workspace.clone();
736 app.current_session_id = Some("test-session".to_string());
737
738 let result = patch_undo(&mut app);
739 assert!(!result.is_error);
740 assert!(
741 result
742 .message
743 .as_deref()
744 .is_some_and(|m| m.contains("Refusing to undo workspace files")),
745 "expected refusal message, got: {:?}",
746 result.message
747 );
748 // Workspace must be untouched by the gate.
749 assert_eq!(
750 std::fs::read_to_string(workspace.join("a.txt")).unwrap(),
751 "modified"
752 );
753 }
754
755 #[test]
756 fn test_patch_undo_never_crosses_session_boundary() {
757 use crate::snapshot::SnapshotRepo;
758 use tempfile::tempdir;
759
760 let tmp = tempdir().unwrap();
761 let workspace = tmp.path().join("ws");
762 std::fs::create_dir_all(&workspace).unwrap();
763 let _guard = crate::test_support::SealedHome::at(tmp.path());
764
765 let repo = SnapshotRepo::open_or_init(&workspace).unwrap();
766 let file = workspace.join("a.txt");
767
768 // Session A: an earlier conversation that modified the workspace.
769 std::fs::write(&file, b"a-before").unwrap();
770 repo.snapshot_with_session("pre-turn:1", Some("session-a"))
771 .unwrap();
772 std::fs::write(&file, b"a-after").unwrap();
773
774 // Session B (current): a later conversation that also modified it.
775 std::fs::write(&file, b"b-before").unwrap();
776 repo.snapshot_with_session("pre-turn:1", Some("session-b"))
777 .unwrap();
778 std::fs::write(&file, b"b-after").unwrap();
779
780 let mut app = create_test_app();
781 app.workspace = workspace.clone();
782 app.yolo = true;
783 app.current_session_id = Some("session-b".to_string());
784
785 let result = patch_undo(&mut app);
786 assert!(!result.is_error);
787 // Must restore session B's pre-turn state — never session A's.
788 assert_eq!(std::fs::read_to_string(&file).unwrap(), "b-before");
789
790 let repeated = patch_undo(&mut app);
791 assert!(!repeated.is_error);
792 assert!(
793 repeated
794 .message
795 .as_deref()
796 .is_some_and(|m| m.contains("No undoable snapshot")),
797 "repeated undo must stop at the session boundary: {:?}",
798 repeated.message
799 );
800 assert_eq!(std::fs::read_to_string(&file).unwrap(), "b-before");
801 }
802
803 /// `/undo` used to report only `Removed N message(s)` when the snapshot repo
804 /// could not be opened, implying a file rollback that never happened. The
805 /// conversation-only fallback must say so, and say why.
806 #[test]
807 fn test_undo_reports_that_files_were_not_reverted_when_the_repo_is_unavailable() {
808 use crate::test_support::SealedHome;
809 use tempfile::tempdir;
810
811 let tmp = tempdir().unwrap();
812 let _home = SealedHome::at(tmp.path());
813
814 // The home directory itself is refused by the snapshot safety gate, so
815 // `patch_undo` cannot open a repo at all.
816 let mut app = create_test_app();
817 app.workspace = tmp.path().to_path_buf();
818 app.current_session_id = Some("test-session".to_string());
819 app.yolo = true;
820 app.history.push(HistoryCell::User {
821 content: "change something".to_string(),
822 });
823 app.api_messages_mut().push(Message {
824 role: Role::User,
825 content: vec![ContentBlock::Text {
826 text: "change something".to_string(),
827 cache_control: None,
828 }],
829 });
830
831 let result = super::execute("/undo", &mut app);
832 let message = result.message.as_deref().unwrap_or_default();
833 assert!(
834 message.contains("Removed 1 message(s)"),
835 "conversation undo still runs: {message}"
836 );
837 assert!(
838 message.contains(undo::FILES_NOT_REVERTED_NOTE),
839 "the user must be told files were not reverted: {message}"
840 );
841 assert!(
842 message.contains(undo::SNAPSHOT_REPO_UNAVAILABLE_PREFIX),
843 "the reason must travel with the fallback: {message}"
844 );
845 }
846
847 /// Isolated HOME + workspace for the `/undo` restore tests (#6644).
848 // Fields drop in order: the seal restores the environment and releases the
849 // lock before the directory it pointed at is deleted.
850 struct UndoFixture {
851 workspace: PathBuf,
852 repo: crate::snapshot::SnapshotRepo,
853 _home: crate::test_support::SealedHome,
854 _tmp: tempfile::TempDir,
855 }
856
857 impl UndoFixture {
858 fn new() -> Self {
859 let tmp = tempfile::tempdir().unwrap();
860 let home = crate::test_support::SealedHome::at(tmp.path());
861 let workspace = tmp.path().join("ws");
862 std::fs::create_dir_all(&workspace).unwrap();
863 let repo = crate::snapshot::SnapshotRepo::open_or_init(&workspace).unwrap();
864 Self {
865 workspace,
866 repo,
867 _home: home,
868 _tmp: tmp,
869 }
870 }
871
872 fn write(&self, name: &str, body: &str) {
873 std::fs::write(self.workspace.join(name), body).unwrap();
874 }
875
876 fn read(&self, name: &str) -> String {
877 std::fs::read_to_string(self.workspace.join(name)).unwrap()
878 }
879
880 fn snapshot(&self, label: &str, session: &str) {
881 self.repo.take_snapshot(label, Some(session)).unwrap();
882 }
883
884 fn app(&self, session: &str) -> App {
885 let mut app = create_test_app();
886 app.workspace = self.workspace.clone();
887 app.yolo = true;
888 app.current_session_id = Some(session.to_string());
889 app
890 }
891 }
892
893 /// `/undo` restores only the paths the undone turn changed: an edit the user
894 /// made to another file after the turn survives. The whole-tree restore
895 /// reverted it too.
896 #[test]
897 fn patch_undo_restores_only_the_paths_the_undone_step_changed() {
898 let fx = UndoFixture::new();
899 fx.write("a.txt", "a0");
900 fx.write("b.txt", "b0");
901 fx.snapshot("pre-turn:1", "s1");
902 fx.write("a.txt", "a1");
903 fx.write("new.txt", "created by the turn");
904 fx.snapshot("post-turn:1", "s1");
905 // The user's own edit after the turn, and a file they created.
906 fx.write("b.txt", "b-user");
907 fx.write("mine.txt", "user file");
908
909 let mut app = fx.app("s1");
910 let result = patch_undo(&mut app);
911
912 assert!(!result.is_error, "{:?}", result.message);
913 assert_eq!(fx.read("a.txt"), "a0");
914 assert!(!fx.workspace.join("new.txt").exists());
915 assert_eq!(fx.read("b.txt"), "b-user");
916 assert_eq!(fx.read("mine.txt"), "user file");
917 let message = result.message.unwrap_or_default();
918 assert!(
919 message.contains("modified a.txt") && message.contains("removed new.txt"),
920 "{message}"
921 );
922 }
923
924 /// A path the undone step changed that changed again since is refused, not
925 /// overwritten, and nothing else is touched.
926 #[test]
927 fn patch_undo_refuses_when_a_changed_path_changed_since() {
928 let fx = UndoFixture::new();
929 fx.write("a.txt", "a0");
930 fx.write("b.txt", "b0");
931 fx.snapshot("pre-turn:1", "s1");
932 fx.write("a.txt", "a1");
933 fx.write("b.txt", "b1");
934 fx.snapshot("post-turn:1", "s1");
935 fx.write("a.txt", "a-user");
936
937 let mut app = fx.app("s1");
938 let result = super::execute("/undo", &mut app);
939
940 let message = result.message.unwrap_or_default();
941 assert!(
942 message.contains("Refusing to undo snapshot") && message.contains("a.txt"),
943 "{message}"
944 );
945 assert_eq!(fx.read("a.txt"), "a-user");
946 assert_eq!(fx.read("b.txt"), "b1");
947 }
948
949 /// `/undo` keeps stepping back one tool call at a time (#384), each step
950 /// restoring only what that call changed.
951 #[test]
952 fn patch_undo_steps_back_one_tool_call_at_a_time() {
953 let fx = UndoFixture::new();
954 fx.write("a.txt", "a0");
955 fx.snapshot("pre-turn:1", "s1");
956 fx.snapshot("tool:call-1", "s1");
957 fx.write("a.txt", "a1");
958 fx.snapshot("tool:call-2", "s1");
959 fx.write("a.txt", "a2");
960 fx.write("b.txt", "b2");
961 fx.snapshot("post-turn:1", "s1");
962
963 let mut app = fx.app("s1");
964 let first = patch_undo(&mut app);
965 assert!(!first.is_error, "{:?}", first.message);
966 assert_eq!(fx.read("a.txt"), "a1");
967 assert!(!fx.workspace.join("b.txt").exists());
968
969 let second = patch_undo(&mut app);
970 assert!(!second.is_error, "{:?}", second.message);
971 assert_eq!(fx.read("a.txt"), "a0");
972
973 let third = patch_undo(&mut app);
974 assert!(
975 third
976 .message
977 .as_deref()
978 .is_some_and(|m| m.starts_with("No undoable snapshot")),
979 "{:?}",
980 third.message
981 );
982 }
983
984 /// Restore points older than the newest 100 snapshots are still found.
985 #[test]
986 fn patch_undo_finds_restore_points_beyond_the_newest_hundred_snapshots() {
987 let fx = UndoFixture::new();
988 fx.write("a.txt", "a0");
989 fx.snapshot("pre-turn:1", "s1");
990 fx.write("a.txt", "a1");
991 fx.snapshot("post-turn:1", "s1");
992 for i in 0..101 {
993 fx.repo
994 .take_snapshot(&format!("tool:other-{i}"), Some("other-session"))
995 .unwrap();
996 }
997
998 let mut app = fx.app("s1");
999 let result = patch_undo(&mut app);
1000
1001 assert!(!result.is_error, "{:?}", result.message);
1002 assert_eq!(fx.read("a.txt"), "a0", "{:?}", result.message);
1003 }
1004
1005 /// A fork owns the restore points of the turns it inherited, up to the fork,
1006 /// and none its source took afterwards.
1007 #[test]
1008 fn patch_undo_restores_turns_a_fork_inherited() {
1009 let fx = UndoFixture::new();
1010 fx.write("a.txt", "a0");
1011 fx.snapshot("pre-turn:1", "source");
1012 fx.write("a.txt", "a1");
1013 fx.snapshot("post-turn:1", "source");
1014
1015 let fork = |created_at: chrono::DateTime<chrono::Utc>| {
1016 let mut app = fx.app("fork");
1017 let mut metadata =
1018 crate::session_manager::create_saved_session(&[], "model", &fx.workspace, 0, None)
1019 .metadata;
1020 metadata.id = "fork".to_string();
1021 metadata.parent_session_id = Some("source".to_string());
1022 metadata.created_at = created_at;
1023 app.current_session_metadata = Some(metadata);
1024 app
1025 };
1026
1027 let owners = super::contract::debug_operations::snapshot_owners(&fork(chrono::Utc::now()));
1028 assert_eq!(owners.len(), 2);
1029 assert_eq!(owners[1].session_id, "source");
1030
1031 // Forked before the source took these snapshots: they are not the fork's.
1032 let mut early = fork(chrono::Utc::now() - chrono::Duration::hours(1));
1033 let refused = patch_undo(&mut early);
1034 assert!(
1035 refused
1036 .message
1037 .as_deref()
1038 .is_some_and(|m| m.starts_with("No undoable snapshot")),
1039 "{:?}",
1040 refused.message
1041 );
1042 assert_eq!(fx.read("a.txt"), "a1");
1043
1044 let mut app = fork(chrono::Utc::now() + chrono::Duration::seconds(5));
1045 let result = patch_undo(&mut app);
1046 assert!(!result.is_error, "{:?}", result.message);
1047 assert_eq!(fx.read("a.txt"), "a0", "{:?}", result.message);
1048 }
1049
1050 /// `/undo` typed while the post-turn snapshot is still being written waits
1051 /// for it (#6644). Before, the two raced on the side repo: the post-turn
1052 /// snapshot landed after the undo and recorded the reverted workspace as
1053 /// the turn's end, and the undone step ended at "now".
1054 #[test]
1055 fn patch_undo_waits_for_a_pending_post_turn_snapshot() {
1056 let fx = UndoFixture::new();
1057 fx.write("a.txt", "a0");
1058 fx.snapshot("pre-turn:1", "s1");
1059 fx.snapshot("tool:call-1", "s1");
1060 fx.write("a.txt", "a1");
1061
1062 // The turn has completed; its post-turn snapshot is still in flight.
1063 let pending = crate::snapshot::PendingPostTurnSnapshot::reserve();
1064 let writer = {
1065 let repo = crate::snapshot::SnapshotRepo::open_or_init(&fx.workspace).unwrap();
1066 std::thread::spawn(move || {
1067 std::thread::sleep(std::time::Duration::from_millis(500));
1068 let taken = repo.take_snapshot("post-turn:1", Some("s1")).unwrap();
1069 drop(pending);
1070 taken
1071 })
1072 };
1073
1074 let mut app = fx.app("s1");
1075 let result = patch_undo(&mut app);
1076 let post_turn = writer.join().unwrap();
1077
1078 assert!(!result.is_error, "{:?}", result.message);
1079 assert_eq!(fx.read("a.txt"), "a0", "{:?}", result.message);
1080 let tool = fx
1081 .repo
1082 .list(usize::MAX)
1083 .unwrap()
1084 .into_iter()
1085 .find(|snapshot| snapshot.label == "tool:call-1")
1086 .unwrap();
1087 assert_eq!(
1088 fx.repo
1089 .changed_paths_between(&tool.tree, &post_turn.tree)
1090 .unwrap(),
1091 vec![PathBuf::from("a.txt")],
1092 "the post-turn snapshot must record the turn's end, not the undone workspace"
1093 );
1094 }
1095
1096 /// A step that changed a symlink restores its regular files and reports the
1097 /// symlink, and it does not block older steps.
1098 #[cfg(unix)]
1099 #[test]
1100 fn patch_undo_skips_non_regular_paths_without_blocking_older_steps() {
1101 let fx = UndoFixture::new();
1102 fx.write("a.txt", "a0");
1103 fx.snapshot("pre-turn:1", "s1");
1104 fx.write("a.txt", "a1");
1105 fx.snapshot("pre-turn:2", "s1");
1106 fx.write("a.txt", "a2");
1107 std::os::unix::fs::symlink("a.txt", fx.workspace.join("current")).unwrap();
1108 fx.snapshot("post-turn:2", "s1");
1109
1110 let mut app = fx.app("s1");
1111 let first = patch_undo(&mut app);
1112 assert!(!first.is_error, "{:?}", first.message);
1113 assert_eq!(fx.read("a.txt"), "a1");
1114 let message = first.message.unwrap_or_default();
1115 assert!(
1116 message.contains("Left in place") && message.contains("current"),
1117 "{message}"
1118 );
1119 assert!(
1120 std::fs::symlink_metadata(fx.workspace.join("current"))
1121 .unwrap()
1122 .file_type()
1123 .is_symlink()
1124 );
1125
1126 let second = patch_undo(&mut app);
1127 assert!(!second.is_error, "{:?}", second.message);
1128 assert_eq!(fx.read("a.txt"), "a0", "{:?}", second.message);
1129 }
1130
1131 /// Outside trusted mode `/undo` refuses before writing anything: planning
1132 /// the newest step does not add a snapshot to the side repo.
1133 #[test]
1134 fn patch_undo_outside_trusted_mode_writes_no_snapshot() {
1135 let fx = UndoFixture::new();
1136 fx.write("a.txt", "a0");
1137 fx.snapshot("pre-turn:1", "s1");
1138 fx.write("a.txt", "a1");
1139 let before = fx.repo.list(usize::MAX).unwrap().len();
1140
1141 let mut app = fx.app("s1");
1142 app.yolo = false;
1143 app.trust_mode = false;
1144 let result = patch_undo(&mut app);
1145
1146 assert!(
1147 result
1148 .message
1149 .as_deref()
1150 .is_some_and(|m| m.starts_with("Refusing to undo workspace files outside trusted mode")),
1151 "{:?}",
1152 result.message
1153 );
1154 assert_eq!(fx.repo.list(usize::MAX).unwrap().len(), before);
1155 assert_eq!(fx.read("a.txt"), "a1");
1156 }
1157
1158 #[test]
1159 fn receipts_command_is_registered_and_reads_the_transcript() {
1160 assert_eq!(
1161 crate::commands::get_command_info("receipts").map(|info| info.name),
1162 Some("receipts")
1163 );
1164 assert_eq!(
1165 crate::commands::get_command_info("receipt").map(|info| info.name),
1166 Some("receipts")
1167 );
1168 let mut app = create_test_app();
1169 app.current_session_id = None;
1170 let empty = crate::commands::execute("/receipts", &mut app);
1171 assert!(!empty.is_error, "{:?}", empty.message);
1172 assert!(
1173 empty
1174 .message
1175 .as_deref()
1176 .is_some_and(|text| text.contains("No actions recorded.")),
1177 "{:?}",
1178 empty.message
1179 );
1180
1181 app.api_messages_mut().push(Message {
1182 role: Role::User,
1183 content: vec![ContentBlock::Text {
1184 text: "run the tests".to_string(),
1185 cache_control: None,
1186 }],
1187 });
1188 app.api_messages_mut().push(Message {
1189 role: Role::Assistant,
1190 content: vec![ContentBlock::ToolUse {
1191 execution_id: None,
1192 id: "call-1".to_string(),
1193 name: "bash".to_string(),
1194 input: serde_json::json!({"command": "cargo test"}),
1195 caller: None,
1196 thought_signature: None,
1197 }],
1198 });
1199 app.api_messages_mut().push(Message {
1200 role: Role::User,
1201 content: vec![ContentBlock::ToolResult {
1202 execution_id: None,
1203 tool_use_id: "call-1".to_string(),
1204 content: "ok".to_string(),
1205 is_error: None,
1206 content_blocks: None,
1207 }],
1208 });
1209 let listed = crate::commands::execute("/receipts", &mut app);
1210 let text = listed.message.expect("receipt text");
1211 assert!(text.contains("Ran 1 command"), "{text}");
1212 assert!(text.contains("1. ran `cargo test`"), "{text}");
1213
1214 // `$`, `*`, and `_` in a command must reach the note cell as JSON, not
1215 // as math or emphasis.
1216 let shell = r#"echo "$HOME" && echo $PATH *_x_*"#;
1217 app.api_messages_mut().push(Message {
1218 role: Role::Assistant,
1219 content: vec![ContentBlock::ToolUse {
1220 execution_id: None,
1221 id: "call-2".to_string(),
1222 name: "bash".to_string(),
1223 input: serde_json::json!({ "command": shell }),
1224 caller: None,
1225 thought_signature: None,
1226 }],
1227 });
1228 app.api_messages_mut().push(Message {
1229 role: Role::User,
1230 content: vec![ContentBlock::ToolResult {
1231 execution_id: None,
1232 tool_use_id: "call-2".to_string(),
1233 content: "ok".to_string(),
1234 is_error: None,
1235 content_blocks: None,
1236 }],
1237 });
1238 let json = crate::commands::execute("/receipts json", &mut app);
1239 let fenced = json.message.expect("json");
1240 let body = fenced
1241 .strip_prefix("```json\n")
1242 .and_then(|rest| rest.strip_suffix("\n```"))
1243 .expect("the JSON is fenced");
1244 let value: serde_json::Value = serde_json::from_str(body).expect("valid json");
1245 assert_eq!(value["totals"]["commands"], 2);
1246 let rendered: String = HistoryCell::System { content: fenced }
1247 .lines(400)
1248 .iter()
1249 .map(|line| {
1250 line.spans
1251 .iter()
1252 .map(|span| span.content.as_ref())
1253 .collect::<String>()
1254 })
1255 .collect::<Vec<_>>()
1256 .join("\n");
1257 assert!(
1258 rendered.contains(r#""command": "echo \"$HOME\" && echo $PATH *_x_*""#),
1259 "{rendered}"
1260 );
1261 let bad = crate::commands::execute("/receipts nope", &mut app);
1262 assert!(bad.is_error);
1263 }
1264
1265 #[test]
1266 fn whole_debug_registry_matches_portable_inventory_and_exact_host_authority() {
1267 use codewhale_command_contract::handler::{
1268 CommandCapabilities as Caps, CommandHandler, ContextParts,
1269 };
1270 let mut app = create_test_app();
1271 let portable = super::groups::debug::portable_handlers();
1272 assert_eq!(portable.len(), 14);
1273 for (info, portable_handler) in portable {
1274 for spelling in std::iter::once(info.name).chain(info.aliases.iter().copied()) {
1275 let registered = super::registry()
1276 .get(spelling)
1277 .expect("registered debug command");
1278 assert_eq!(registered.info().name, info.name);
1279 assert_eq!(registered.info().aliases, info.aliases);
1280 assert_eq!(registered.info().usage, info.usage);
1281 let handler = registered
1282 .contextual_handler()
1283 .expect("portable host registration");
1284 match (portable_handler.clone(), handler) {
1285 (CommandHandler::Pure(_), CommandHandler::Pure(_)) => {
1286 assert_eq!(info.name, "preview-request")
1287 }
1288 (
1289 CommandHandler::Contextual {
1290 capabilities: expected,
1291 ..
1292 },
1293 CommandHandler::Contextual { capabilities, .. },
1294 ) => {
1295 assert_eq!(capabilities, expected, "/{spelling}");
1296 let mut bundle = app.command_contexts();
1297 let ContextParts {
1298 session,
1299 model,
1300 cost,
1301 mode_policy,
1302 system_prompt,
1303 skills,
1304 workspace,
1305 presentation,
1306 media,
1307 memory,
1308 project,
1309 skill_group,
1310 plugin,
1311 lifecycle,
1312 control,
1313 export,
1314 structcopy,
1315 debug_diagnostics,
1316 debug_receipts,
1317 debug_change,
1318 debug_history,
1319 debug_diff,
1320 debug_undo,
1321 } = bundle.contexts(capabilities).into_parts();
1322 for (name, present, capability) in [
1323 ("session", session.is_some(), Caps::SESSION),
1324 ("model", model.is_some(), Caps::MODEL),
1325 ("cost", cost.is_some(), Caps::COST),
1326 ("mode_policy", mode_policy.is_some(), Caps::MODE_POLICY),
1327 (
1328 "system_prompt",
1329 system_prompt.is_some(),
1330 Caps::SYSTEM_PROMPT,
1331 ),
1332 ("skills", skills.is_some(), Caps::SKILLS),
1333 ("workspace", workspace.is_some(), Caps::WORKSPACE),
1334 ("presentation", presentation.is_some(), Caps::PRESENTATION),
1335 ("media", media.is_some(), Caps::MEDIA),
1336 ("memory", memory.is_some(), Caps::MEMORY),
1337 ("project", project.is_some(), Caps::PROJECT),
1338 ("skill_group", skill_group.is_some(), Caps::SKILL_GROUP),
1339 ("plugin", plugin.is_some(), Caps::PLUGIN),
1340 ("lifecycle", lifecycle.is_some(), Caps::SESSION_LIFECYCLE),
1341 ("control", control.is_some(), Caps::SESSION_CONTROL),
1342 ("export", export.is_some(), Caps::SESSION_EXPORT),
1343 ("structcopy", structcopy.is_some(), Caps::SESSION_STRUCTCOPY),
1344 (
1345 "debug_diagnostics",
1346 debug_diagnostics.is_some(),
1347 Caps::DEBUG_DIAGNOSTICS,
1348 ),
1349 (
1350 "debug_receipts",
1351 debug_receipts.is_some(),
1352 Caps::DEBUG_RECEIPTS,
1353 ),
1354 ("debug_change", debug_change.is_some(), Caps::DEBUG_CHANGE),
1355 (
1356 "debug_history",
1357 debug_history.is_some(),
1358 Caps::DEBUG_HISTORY,
1359 ),
1360 ("debug_diff", debug_diff.is_some(), Caps::DEBUG_DIFF),
1361 ("debug_undo", debug_undo.is_some(), Caps::DEBUG_UNDO),
1362 ] {
1363 assert_eq!(
1364 present,
1365 capabilities.contains(capability),
1366 "/{spelling}: {name}"
1367 );
1368 }
1369 }
1370 _ => panic!("host changed /{spelling} handler shape"),
1371 }
1372 }
1373 }
1374 }
1375
1375 lines RUST