返回 CodeWhale
conversation_undo.rs
根目录 / crates / tui / src / tui / ui / tests / conversation_undo.rs
1 //! Exercise command dispatch, the actual UI apply path, Engine requests and disk.
2 use super::*;
3 use crate::core::engine::{Engine, EngineConfig};
4 use crate::core::events::TurnOutcomeStatus;
5 use crate::llm_client::mock::{MockLlmClient, canned};
6 use codewhale_models::{ContentBlock, Message};
7
8 fn prompts(messages: &[Message]) -> Vec<String> {
9 messages
10 .iter()
11 .filter(|message| {
12 matches!(
13 crate::runtime_handoff::classify_user_turn_prompt(message),
14 crate::runtime_handoff::UserTurnPromptKind::Editable
15 )
16 })
17 .flat_map(|message| &message.content)
18 .filter_map(|block| match block {
19 ContentBlock::Text { text, .. } if !text.starts_with("<turn_meta>") => {
20 Some(text.clone())
21 }
22 _ => None,
23 })
24 .collect()
25 }
26
27 async fn settled(app: &mut App, handle: &EngineHandle, answer: &str) {
28 let mut events = handle.rx_event.write().await;
29 loop {
30 let event = tokio::time::timeout(Duration::from_secs(30), events.recv())
31 .await
32 .expect("turn deadline")
33 .expect("Engine event");
34 if let EngineEvent::TurnComplete { status, error, .. } = event {
35 assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}");
36 break;
37 }
38 }
39 drop(events);
40 let snapshot = handle
41 .get_session_snapshot()
42 .await
43 .expect("settled snapshot");
44 app.current_session_id = Some(snapshot.session_id);
45 app.set_api_messages(Arc::new(snapshot.messages));
46 app.system_prompt = snapshot.system_prompt;
47 app.model = snapshot.model;
48 app.is_loading = false;
49 app.push_history_cell(HistoryCell::Assistant {
50 content: answer.into(),
51 streaming: false,
52 });
53 }
54
55 fn sync_snapshot(snapshot: &crate::core::ops::SessionSnapshot) -> Op {
56 Op::SyncSession {
57 session_id: Some(snapshot.session_id.clone()),
58 messages: snapshot.messages.clone(),
59 system_prompt: snapshot.system_prompt.clone(),
60 system_prompt_override: false,
61 model: snapshot.model.clone(),
62 workspace: snapshot.workspace.clone(),
63 mode: AppMode::parse(&snapshot.mode).unwrap(),
64 }
65 }
66
67 async fn flush_persistence(actor: &persistence_actor::PersistActorHandle) {
68 let (reply, receive) = tokio::sync::oneshot::channel();
69 assert!(actor.try_send(PersistRequest::FlushAndReport { reply }));
70 assert!(receive.await.unwrap().failures.is_empty());
71 }
72
73 #[test]
74 fn undo_retry_apply_path_keeps_engine_request_and_reopened_session_consistent() {
75 const PROBE: &str = "CODEWHALE_UNDO_RETRY_APPLY_PROBE";
76 if std::env::var_os(PROBE).is_none() {
77 // The production actor is process-global. Never replace another
78 // test's actor or leave this fixture's closed sender in its process.
79 let output = Command::new(std::env::current_exe().unwrap())
80 .args([
81 "--exact",
82 "tui::ui::tests::conversation_undo::undo_retry_apply_path_keeps_engine_request_and_reopened_session_consistent",
83 "--nocapture",
84 "--test-threads=1",
85 ])
86 .env(PROBE, "1")
87 .output()
88 .expect("isolated UI lifecycle");
89 assert!(
90 output.status.success(),
91 "{}\n{}",
92 String::from_utf8_lossy(&output.stdout),
93 String::from_utf8_lossy(&output.stderr)
94 );
95 assert!(String::from_utf8_lossy(&output.stdout).contains("1 passed"));
96 return;
97 }
98 let _environment = crate::test_support::lock_test_env();
99 let home = TempDir::new().unwrap();
100 let workspace = TempDir::new().unwrap();
101 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
102 let _user_home = crate::test_support::EnvVarGuard::set("HOME", home.path());
103 let _profile = crate::test_support::EnvVarGuard::set("USERPROFILE", home.path());
104 let runtime = tokio::runtime::Builder::new_current_thread()
105 .enable_all()
106 .build()
107 .unwrap();
108 runtime.block_on(async {
109 let mut config = Config::default().with_legacy_root(
110 Some("mock-credential".into()),
111 Some("http://127.0.0.1:1/v1".into()),
112 );
113 config.set_feature("mcp", false).unwrap();
114 config.set_feature("subagents", false).unwrap();
115 let mut app = App::new(
116 crate::test_support::test_tui_options(workspace.path()),
117 &config,
118 );
119 app.onboarding_needs_api_key = false;
120 app.offline_mode = false;
121 app.mode = AppMode::Agent;
122 let mock = Arc::new(MockLlmClient::new(vec![
123 canned::simple_text_turn("kept answer"),
124 canned::simple_text_turn("undone answer"),
125 canned::simple_text_turn("retry answer one"),
126 canned::simple_text_turn("retry answer two"),
127 canned::simple_text_turn("answer after reopen"),
128 canned::simple_text_turn("edited answer"),
129 ]));
130 let (engine, mut handle) = Engine::new_with_model_client(
131 EngineConfig {
132 workspace: workspace.path().to_path_buf(),
133 model: app.model.clone(),
134 snapshots_enabled: false,
135 subagents_enabled: false,
136 terminal_chrome_enabled: false,
137 ..EngineConfig::default()
138 },
139 &config,
140 mock.clone(),
141 );
142 let engine_task = tokio::spawn(engine.run());
143 let manager = SessionManager::default_location().unwrap();
144 let (actor, actor_task) =
145 persistence_actor::spawn_persistence_actor(SessionManager::default_location().unwrap());
146 persistence_actor::init_actor(actor.clone());
147 let tasks = TaskManager::start(
148 TaskManagerConfig::from_runtime(&config, workspace.path().into(), None, Some(1)),
149 config.clone(),
150 app.plugin_registry.clone(),
151 "undo-fixture-tasks",
152 None,
153 )
154 .await
155 .unwrap();
156 let mut backend = ColorCompatBackend::new(
157 std::io::stdout(),
158 codewhale_palette::ColorDepth::Monochrome,
159 codewhale_palette::PaletteMode::Dark,
160 );
161 backend.set_terminal_size(Size::new(80, 24));
162 let mut terminal = Terminal::new(backend).unwrap();
163
164 for (prompt, answer) in [
165 ("keep this", "kept answer"),
166 ("retry this", "undone answer"),
167 ] {
168 apply_command_result(
169 &mut terminal,
170 &mut app,
171 &mut handle,
172 &tasks,
173 &mut config,
174 commands::CommandResult {
175 message: None,
176 action: Some(AppAction::SendMessage(prompt.into())),
177 is_error: false,
178 },
179 )
180 .await
181 .unwrap();
182 settled(&mut app, &handle, answer).await;
183 }
184 // Seed the Engine's typed compaction format, then consume the real
185 // SessionUpdated projection. Current projections strip the legacy
186 // system-prompt carrier; history must retain the checkpoint on undo.
187 let checkpoint_text = format!("{}\nRetained earlier facts", crate::compaction::SUMMARY_HEADER);
188 let checkpoint = crate::compaction::compaction_checkpoint_message(
189 &SystemPrompt::Text(checkpoint_text.clone()),
190 );
191 let mut compacted = handle.get_session_snapshot().await.unwrap();
192 let stable_prompt = compacted.system_prompt.as_ref()
193 .map(crate::compaction::summary_prompt_text).unwrap_or_default();
194 compacted.messages.insert(0, checkpoint.clone());
195 compacted.system_prompt = Some(SystemPrompt::Text(format!(
196 "{stable_prompt}\n\n<!-- compaction-summary:begin -->\n{checkpoint_text}\n<!-- compaction-summary:end -->"
197 )));
198 handle.send(sync_snapshot(&compacted)).await.unwrap();
199 loop {
200 let event = handle.rx_event.write().await.recv().await.unwrap();
201 if matches!(&event, EngineEvent::SessionUpdated { messages, .. } if messages.contains(&checkpoint)) {
202 assert!(super::super::event_loop::apply_engine_session_projection(&mut app, &config, event));
203 break;
204 }
205 }
206 assert!(crate::compaction::extract_compaction_summary(app.system_prompt.as_ref()).is_none());
207 assert!(app.api_messages.contains(&checkpoint));
208 let id = app.current_session_id.clone().unwrap();
209 let old = build_session_snapshot(&mut app, &manager).unwrap();
210 let path = manager.save_session(&old).unwrap();
211 // The stale in-flight recovery record must not revive the undone turn.
212 assert!(actor.try_send(PersistRequest::SaveCheckpoint { session: old }));
213 flush_persistence(&actor).await;
214 assert!(
215 serde_json::to_string(
216 &manager
217 .load_session_checkpoint(&id)
218 .unwrap()
219 .unwrap()
220 .messages
221 )
222 .unwrap()
223 .contains("undone answer")
224 );
225
226 for answer in ["retry answer one", "retry answer two"] {
227 let result = commands::execute("/retry", &mut app);
228 assert!(matches!(
229 result.action,
230 Some(AppAction::ConversationUndo { .. })
231 ));
232 apply_command_result(
233 &mut terminal,
234 &mut app,
235 &mut handle,
236 &tasks,
237 &mut config,
238 result,
239 )
240 .await
241 .unwrap();
242 settled(&mut app, &handle, answer).await;
243 let requests = mock.captured_requests();
244 let last = requests.last().unwrap();
245 assert_eq!(prompts(&last.messages), ["keep this", "retry this"]);
246 assert!(last.messages.contains(&checkpoint), "retry request lost retained checkpoint");
247 assert!(
248 !serde_json::to_string(&last.messages)
249 .unwrap()
250 .contains("undone answer")
251 );
252 let reopened = manager.load_session(&id).unwrap();
253 assert!(reopened.messages.contains(&checkpoint), "durable undo lost retained checkpoint");
254 assert_eq!(
255 prompts(&reopened.messages),
256 ["keep this"],
257 "rollback is durable before replacement inference"
258 );
259 // Dispatch starts a fresh recovery checkpoint. This fixture reads
260 // TurnComplete directly, so it does not run the UI completion save
261 // that would retire that new checkpoint. Inspect its history rather
262 // than incorrectly expecting the replacement turn to have none.
263 flush_persistence(&actor).await;
264 let checkpoint = manager.load_session_checkpoint(&id).unwrap().unwrap();
265 assert_eq!(prompts(&checkpoint.messages), ["keep this", "retry this"]);
266 let checkpoint_text = serde_json::to_string(&checkpoint.messages).unwrap();
267 assert!(!checkpoint_text.contains("undone answer"));
268 assert!(!checkpoint_text.contains("retry answer"));
269 }
270 assert_eq!(mock.captured_requests().len(), 4);
271
272 // Force a real Engine update between successful UI preflight and the
273 // rewind operation. The proxy only orders mailbox operations: snapshots,
274 // mutation, refusal and provider calls still belong to the real Engine.
275 // Same-length history changes must be caught, as must each context field.
276 let alternate_workspace = TempDir::new().unwrap();
277 for (changed_field, before_preflight) in [
278 ("messages", false), ("identity", false), ("model", false),
279 ("workspace", false), ("prompt", false), ("mode", false),
280 ("prompt", true), ("mode", true),
281 ] {
282 let original = handle.get_session_snapshot().await.unwrap();
283 let mut newer = original.clone();
284 match changed_field {
285 "messages" => {
286 let ContentBlock::Text { text, .. } =
287 &mut newer.messages.last_mut().unwrap().content[0]
288 else {
289 panic!("fixture answer should be text");
290 };
291 *text = "a newer owned answer".into();
292 }
293 "identity" => newer.session_id = "newer-conversation".into(),
294 "model" => newer.model = "newer-model".into(),
295 "workspace" => newer.workspace = alternate_workspace.path().to_path_buf(),
296 "prompt" => newer.system_prompt = Some(SystemPrompt::Text("newer instructions".into())),
297 "mode" => newer.mode = AppMode::Plan.as_setting().into(),
298 _ => unreachable!(),
299 }
300 let replacement = sync_snapshot(&newer);
301 let real_handle = handle.clone();
302 let queued = real_handle.clone();
303 let (proxy_tx, mut proxy_rx) = tokio::sync::mpsc::channel(8);
304 handle.tx_op = proxy_tx;
305 let interleaving = tokio::spawn(async move {
306 let mut replacement = Some(replacement);
307 while let Some(op) = proxy_rx.recv().await {
308 let is_preflight = matches!(&op, Op::GetSessionSnapshot { .. });
309 if is_preflight && before_preflight && let Some(replacement) = replacement.take() {
310 queued.send(replacement).await.unwrap();
311 }
312 queued.send(op).await.unwrap();
313 if is_preflight && let Some(replacement) = replacement.take() {
314 // FIFO puts B after snapshot A, but before the UI can
315 // enqueue its conditional rewind through this proxy.
316 queued.send(replacement).await.unwrap();
317 }
318 }
319 assert!(replacement.is_none(), "preflight snapshot must have run");
320 });
321 app.input = "keep this unsent draft".into();
322 app.cursor_position = 7;
323 let visible = app.api_messages.clone();
324 let visible_id = app.current_session_id.clone();
325 let visible_model = app.model.clone();
326 let visible_workspace = app.workspace.clone();
327 let visible_prompt = app.system_prompt.clone();
328 let history_len = app.history.len();
329 let result = commands::execute("/retry", &mut app);
330 apply_command_result(
331 &mut terminal,
332 &mut app,
333 &mut handle,
334 &tasks,
335 &mut config,
336 result,
337 )
338 .await
339 .unwrap();
340 handle = real_handle;
341 interleaving.await.unwrap();
342 let current = handle.get_session_snapshot().await.unwrap();
343 assert!(current == newer, "queued {changed_field} update must not be overwritten");
344 assert!(app.api_messages == visible, "visible history changed on refusal");
345 assert!(app.current_session_id == visible_id, "visible identity changed on refusal");
346 assert!(app.model == visible_model, "visible model changed on refusal");
347 assert!(app.workspace == visible_workspace, "visible workspace changed on refusal");
348 assert!(app.system_prompt == visible_prompt, "visible prompt changed on refusal");
349 assert_eq!(app.history.len(), history_len);
350 assert!(matches!(app.history.last(), Some(HistoryCell::Assistant { content, .. }) if content == "retry answer two"));
351 assert_eq!(app.input, "keep this unsent draft");
352 assert_eq!(app.cursor_position, 7);
353 assert_eq!(mock.captured_requests().len(), 4, "refusal must not dispatch retry inference");
354 assert!(app.status_toasts.back().unwrap().text.contains(
355 app.tr(MessageId::ConversationChangedBeforeUndo).as_ref()
356 ));
357 // Restore only the fixture between independent race cases.
358 handle.send(sync_snapshot(&original)).await.unwrap();
359 assert!(handle.get_session_snapshot().await.unwrap() == original);
360 }
361
362 // Runtime Chat ownership and a locally active turn must refuse before
363 // even staging a different Engine history or altering the transcript.
364 let before = app.api_messages.clone();
365 app.remote_control.block_runtime_chat_dispatch_for_tests();
366 let result = commands::execute("/retry", &mut app);
367 apply_command_result(
368 &mut terminal,
369 &mut app,
370 &mut handle,
371 &tasks,
372 &mut config,
373 result,
374 )
375 .await
376 .unwrap();
377 assert_eq!(app.api_messages, before);
378 assert_eq!(mock.captured_requests().len(), 4);
379 app.remote_control = Default::default();
380 app.is_loading = true;
381 let result = commands::execute("/retry", &mut app);
382 apply_command_result(
383 &mut terminal,
384 &mut app,
385 &mut handle,
386 &tasks,
387 &mut config,
388 result,
389 )
390 .await
391 .unwrap();
392 assert_eq!(app.api_messages, before);
393 app.is_loading = false;
394
395 let result = commands::execute("/undo", &mut app);
396 apply_command_result(
397 &mut terminal,
398 &mut app,
399 &mut handle,
400 &tasks,
401 &mut config,
402 result,
403 )
404 .await
405 .unwrap();
406 let engine_saved = handle.get_session_snapshot().await.unwrap();
407 let reopened = SessionManager::default_location()
408 .unwrap()
409 .load_session(&id)
410 .unwrap();
411 assert_eq!(prompts(&engine_saved.messages), ["keep this"]);
412 assert_eq!(prompts(&app.api_messages), ["keep this"]);
413 assert_eq!(prompts(&reopened.messages), ["keep this"]);
414 assert!(
415 manager.load_session_checkpoint(&id).unwrap().is_none(),
416 "standalone undo retires the checkpoint without starting new turn work"
417 );
418 assert!(
419 !serde_json::to_string(&reopened.messages)
420 .unwrap()
421 .contains("retry answer")
422 );
423 assert_eq!(
424 mock.captured_requests().len(),
425 4,
426 "undo performs no inference"
427 );
428
429 // Resume the durable record through the existing UI projection and
430 // SyncSession apply action, then inspect the actual next model request.
431 // File LoadSession always respawns a real client, so use the same
432 // loaded-session projection with our injected model client here.
433 apply_loaded_session(&mut app, &mut config, &reopened).unwrap();
434 let restore = AppAction::SyncSession {
435 session_id: app.current_session_id.clone(),
436 messages: app.api_messages.as_ref().clone(),
437 system_prompt: app.system_prompt.clone(),
438 model: app.model.clone(),
439 workspace: app.workspace.clone(),
440 mode: app.mode,
441 };
442 for action in [restore, AppAction::SendMessage("after reopen".into())] {
443 apply_command_result(
444 &mut terminal,
445 &mut app,
446 &mut handle,
447 &tasks,
448 &mut config,
449 commands::CommandResult {
450 message: None,
451 action: Some(action),
452 is_error: false,
453 },
454 )
455 .await
456 .unwrap();
457 }
458 settled(&mut app, &handle, "answer after reopen").await;
459 let requests = mock.captured_requests();
460 assert_eq!(requests.len(), 5);
461 assert!(requests.last().unwrap().messages.contains(&checkpoint), "reopened provider request lost checkpoint");
462 assert_eq!(
463 prompts(&requests.last().unwrap().messages),
464 ["keep this", "after reopen"]
465 );
466 let inbound = serde_json::to_string(&requests.last().unwrap().messages).unwrap();
467 assert!(!inbound.contains("retry this"));
468 assert!(!inbound.contains("undone answer"));
469 assert!(!inbound.contains("retry answer"));
470
471 // `/edit` then submit replaces the last exchange: the old prompt and
472 // its answer leave the transcript, the provider request and the saved
473 // session, and the edited prompt takes their place.
474 let loaded = commands::execute("/edit", &mut app);
475 assert!(!loaded.is_error, "{:?}", loaded.message);
476 assert_eq!(app.input, "after reopen");
477 assert!(app.edit_in_progress);
478 let result = super::super::event_loop::edit_replacement_result(&mut app, "edited prompt")
479 .expect("a pending edit stages a replacement");
480 assert!(!app.edit_in_progress);
481 apply_command_result(
482 &mut terminal,
483 &mut app,
484 &mut handle,
485 &tasks,
486 &mut config,
487 result,
488 )
489 .await
490 .unwrap();
491 settled(&mut app, &handle, "edited answer").await;
492 let requests = mock.captured_requests();
493 assert_eq!(requests.len(), 6);
494 assert_eq!(
495 prompts(&requests.last().unwrap().messages),
496 ["keep this", "edited prompt"]
497 );
498 let inbound = serde_json::to_string(&requests.last().unwrap().messages).unwrap();
499 assert!(!inbound.contains("after reopen"));
500 assert_eq!(prompts(&app.api_messages), ["keep this", "edited prompt"]);
501 assert_eq!(
502 prompts(&manager.load_session(&id).unwrap().messages),
503 ["keep this"],
504 "the edit rollback is durable before the replacement turn"
505 );
506 // With no edit pending, a submit is an ordinary turn.
507 assert!(
508 super::super::event_loop::edit_replacement_result(&mut app, "plain").is_none()
509 );
510
511 // Return to the retained exchange before probing durable-save failure.
512 let result = commands::execute("/undo", &mut app);
513 apply_command_result(
514 &mut terminal,
515 &mut app,
516 &mut handle,
517 &tasks,
518 &mut config,
519 result,
520 )
521 .await
522 .unwrap();
523 assert_eq!(prompts(&app.api_messages), ["keep this"]);
524
525 // Portable save failure: block the canonical file with a directory.
526 // Preserve the last durable snapshot as a sibling for inspection.
527 std::fs::rename(&path, path.with_extension("before-failure")).unwrap();
528 std::fs::create_dir(&path).unwrap();
529 let result = commands::execute("/retry", &mut app);
530 apply_command_result(
531 &mut terminal,
532 &mut app,
533 &mut handle,
534 &tasks,
535 &mut config,
536 result,
537 )
538 .await
539 .unwrap();
540 assert_eq!(
541 mock.captured_requests().len(),
542 6,
543 "save failure must forbid retry inference"
544 );
545 assert!(
546 app.status_toasts
547 .back()
548 .unwrap()
549 .text
550 .contains("session save failed")
551 );
552 assert!(
553 app.api_messages.as_slice() == [checkpoint.clone()],
554 "acknowledged undo remains visible after save failure"
555 );
556 assert!(
557 handle
558 .get_session_snapshot()
559 .await
560 .unwrap()
561 .messages == [checkpoint]
562 );
563
564 handle.send(Op::Shutdown).await.unwrap();
565 engine_task.await.unwrap();
566 app.set_api_messages(Arc::new(reopened.messages));
567 app.push_history_cell(HistoryCell::User {
568 content: "keep this".into(),
569 });
570 let result = commands::execute("/retry", &mut app);
571 let before = app.api_messages.clone();
572 apply_command_result(
573 &mut terminal,
574 &mut app,
575 &mut handle,
576 &tasks,
577 &mut config,
578 result,
579 )
580 .await
581 .unwrap();
582 assert_eq!(
583 app.api_messages, before,
584 "closed Engine must leave the visible conversation alone"
585 );
586 assert!(
587 app.status_toasts
588 .back()
589 .unwrap()
590 .text
591 .contains("retry was not sent")
592 );
593 assert_eq!(mock.captured_requests().len(), 6);
594 tasks.shutdown_and_wait().await.unwrap();
595 assert!(actor.try_send(PersistRequest::Shutdown));
596 actor_task.await.unwrap();
597 });
598 }
599
600 /// A refused `/edit` rollback has already consumed the revision from the
601 /// composer. It must come back, with edit mode re-armed, not be lost.
602 #[tokio::test]
603 async fn refused_edit_rollback_returns_the_revision_to_the_composer() {
604 use crate::task_manager::{
605 TaskExecutionResult, TaskManagerConfig, TaskStatus, TaskTerminalReason,
606 };
607 struct Idle;
608 #[async_trait::async_trait]
609 impl crate::task_manager::TaskExecutor for Idle {
610 async fn execute(
611 &self,
612 _: crate::task_manager::ExecutionTask,
613 _: tokio::sync::mpsc::Sender<crate::task_manager::TaskExecutionEvent>,
614 _: tokio_util::sync::CancellationToken,
615 ) -> TaskExecutionResult {
616 TaskExecutionResult {
617 status: TaskStatus::Completed,
618 result_text: None,
619 error: None,
620 terminal_reason: TaskTerminalReason::Completed,
621 }
622 }
623 }
624 let root = TempDir::new().unwrap();
625 let tasks = TaskManager::start_with_executor(
626 TaskManagerConfig {
627 data_dir: root.path().into(),
628 worker_count: 1,
629 default_workspace: root.path().into(),
630 default_model: "fixture".into(),
631 default_mode: "plan".into(),
632 allow_shell: false,
633 trust_mode: false,
634 execution_limits: crate::task_manager::TaskExecutionLimits::default(),
635 },
636 Arc::new(Idle),
637 )
638 .await
639 .unwrap();
640 let mut backend = ColorCompatBackend::new(
641 std::io::stdout(),
642 codewhale_palette::ColorDepth::Monochrome,
643 codewhale_palette::PaletteMode::Dark,
644 );
645 backend.set_terminal_size(Size::new(80, 24));
646 let mut terminal = Terminal::new(backend).unwrap();
647 let mut config = Config::default();
648 let mut engine = mock_engine_handle();
649
650 let mut app = create_test_app();
651 app.push_history_cell(HistoryCell::User {
652 content: "original prompt".into(),
653 });
654 let loaded = commands::execute("/edit", &mut app);
655 assert!(!loaded.is_error, "{:?}", loaded.message);
656 assert!(app.edit_in_progress);
657 // The user revises the prompt and presses Enter: the composer is consumed
658 // and the replacement is staged behind a rollback.
659 app.input = "revised prompt".into();
660 let submitted = std::mem::take(&mut app.input);
661 let result = super::super::event_loop::edit_replacement_result(&mut app, &submitted)
662 .expect("a pending edit stages a replacement");
663 assert!(!app.edit_in_progress);
664 assert!(app.input.is_empty());
665 // Force the refusal: the rollback is only allowed on an idle app.
666 app.is_loading = true;
667
668 apply_command_result(
669 &mut terminal,
670 &mut app,
671 &mut engine.handle,
672 &tasks,
673 &mut config,
674 result,
675 )
676 .await
677 .unwrap();
678
679 assert_eq!(app.input, "revised prompt", "the revision is restored");
680 assert_eq!(app.cursor_position, "revised prompt".chars().count());
681 assert!(app.edit_in_progress, "edit mode is active again");
682 assert!(
683 app.history.iter().any(
684 |cell| matches!(cell, HistoryCell::User { content } if content == "original prompt")
685 ),
686 "the refused rollback leaves the original exchange alone"
687 );
688 assert!(
689 app.status_toasts
690 .iter()
691 .any(|toast| toast.text.contains("rollback failed")),
692 "the refusal is still reported"
693 );
694 assert!(
695 engine.rx_op.try_recv().is_err(),
696 "nothing reached the engine"
697 );
698 tasks.shutdown_and_wait().await.unwrap();
699 }
700
700 lines RUST