返回 CodeWhale
session_acceptance.rs
根目录 / crates / tui / src / commands / session_acceptance.rs
1 //! Gherkin acceptance coverage for session command workflows.
2
3 use std::path::PathBuf;
4
5 use chrono::{Duration as ChronoDuration, Utc};
6 use cucumber::{World as _, given, then, when, writer::Stats as _};
7 use tempfile::TempDir;
8
9 use crate::commands::{self, CommandResult};
10 use crate::config::Config;
11 use crate::session_manager::{SavedSession, SessionManager, create_saved_session_with_id_and_mode};
12 use crate::test_support::{EnvVarGuard, lock_test_env};
13 use crate::tui::app::{App, AppAction, TuiOptions};
14 use crate::tui::history::HistoryCell;
15 use crate::tui::views::ModalKind;
16 use codewhale_models::{ContentBlock, Message, Role};
17
18 const FEATURE_NAME: &str = "Session command workflows";
19 const FEATURE_PATH: &str = concat!(
20 env!("CARGO_MANIFEST_DIR"),
21 "/tests/features/session_command_workflows.feature"
22 );
23 const SAVE_LOAD_SCENARIO: &str = "Save and export preserve data while load defers restoration";
24 const FORK_RESUMABLE_SCENARIO: &str = "Fork keeps the original session resumable";
25 const NEW_THEN_FORK_SCENARIO: &str = "New session cannot be forked before messages exist";
26 const CLEAR_THEN_FORK_SCENARIO: &str = "Cleared session cannot be forked before messages exist";
27 const FORK_THEN_NEW_SCENARIO: &str = "Fork followed by new keeps both saved sessions";
28 const FORK_THEN_CLEAR_SCENARIO: &str = "Fork followed by clear keeps both saved sessions";
29 const RENAME_SCENARIO: &str = "Rename updates the active saved session title";
30 const SESSIONS_LIST_SCENARIO: &str = "Sessions list opens the saved session picker";
31 const SESSIONS_PRUNE_SCENARIO: &str = "Sessions prune removes only stale sessions";
32 const CONTEXT_MANAGEMENT_SCENARIO: &str =
33 "Context management commands emit actions without clearing the active session";
34 const SINGULAR_SESSION_SCENARIO: &str = "Singular session command is not registered";
35
36 #[derive(Default, cucumber::World)]
37 struct SessionCommandWorld {
38 tmpdir: Option<TempDir>,
39 app: Option<Box<App>>,
40 save_path: Option<PathBuf>,
41 export_path: Option<PathBuf>,
42 home_path: Option<PathBuf>,
43 original_session_id: Option<String>,
44 fork_session_id: Option<String>,
45 new_session_id: Option<String>,
46 fresh_session_id: Option<String>,
47 stale_session_id: Option<String>,
48 last_message: Option<String>,
49 last_result_is_error: Option<bool>,
50 last_action: Option<AppAction>,
51 }
52
53 impl std::fmt::Debug for SessionCommandWorld {
54 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55 f.debug_struct("SessionCommandWorld")
56 .field("has_tmpdir", &self.tmpdir.is_some())
57 .field("has_app", &self.app.is_some())
58 .field("save_path", &self.save_path)
59 .field("export_path", &self.export_path)
60 .field("home_path", &self.home_path)
61 .field("original_session_id", &self.original_session_id)
62 .field("fork_session_id", &self.fork_session_id)
63 .field("new_session_id", &self.new_session_id)
64 .field("fresh_session_id", &self.fresh_session_id)
65 .field("stale_session_id", &self.stale_session_id)
66 .field("last_message", &self.last_message)
67 .field("last_result_is_error", &self.last_result_is_error)
68 .finish()
69 }
70 }
71
72 #[given("a CodeWhale session workspace with one user message")]
73 fn workspace_with_one_user_message(world: &mut SessionCommandWorld) {
74 let tmpdir = TempDir::new().expect("session workflow TempDir");
75 let mut app = create_test_app_with_tmpdir(&tmpdir);
76 app.api_messages_mut().push(Message {
77 role: Role::User,
78 content: vec![ContentBlock::Text {
79 text: "Remember the whale migration".to_string(),
80 cache_control: None,
81 }],
82 });
83 app.add_message(HistoryCell::User {
84 content: "Remember the whale migration".to_string(),
85 });
86 app.session.total_tokens = 321;
87 app.session.total_conversation_tokens = 321;
88
89 world.save_path = Some(tmpdir.path().join("saved-session.json"));
90 world.export_path = Some(tmpdir.path().join("transcript.md"));
91 world.home_path = Some(tmpdir.path().join("home"));
92 world.app = Some(Box::new(app));
93 world.tmpdir = Some(tmpdir);
94 }
95
96 #[given("a CodeWhale persisted session workspace with one user message")]
97 fn persisted_workspace_with_one_user_message(world: &mut SessionCommandWorld) {
98 workspace_with_one_user_message(world);
99 let original_id = "original-session".to_string();
100 let app = world.app.as_deref_mut().expect("app should exist");
101 app.current_session_id = Some(original_id.clone());
102 world.original_session_id = Some(original_id);
103 persist_active_session(world);
104 }
105
106 #[given("a CodeWhale session workspace with stale and fresh saved sessions")]
107 fn workspace_with_stale_and_fresh_saved_sessions(world: &mut SessionCommandWorld) {
108 workspace_with_one_user_message(world);
109 persist_session_with_age(world, "fresh-session", "Fresh session", 1);
110 persist_session_with_age(world, "stale-session", "Stale session", 30);
111 world.fresh_session_id = Some("fresh-session".to_string());
112 world.stale_session_id = Some("stale-session".to_string());
113 }
114
115 #[when("the user saves the active session")]
116 fn user_saves_active_session(world: &mut SessionCommandWorld) {
117 let save_path = world
118 .save_path
119 .as_ref()
120 .expect("save path should exist")
121 .to_string_lossy()
122 .to_string();
123 let result = execute_isolated(world, &format!("/save {save_path}"));
124 remember_result(world, &result);
125
126 assert!(!result.is_error, "save failed: {:?}", result.message);
127 assert!(
128 world.save_path.as_ref().expect("save path").exists(),
129 "save command should write the session file"
130 );
131 }
132
133 #[when("the user exports the active transcript")]
134 fn user_exports_active_transcript(world: &mut SessionCommandWorld) {
135 let export_path = world
136 .export_path
137 .as_ref()
138 .expect("export path should exist")
139 .to_string_lossy()
140 .to_string();
141 let result = execute_isolated(world, &format!("/export {export_path}"));
142 remember_result(world, &result);
143
144 assert!(!result.is_error, "export failed: {:?}", result.message);
145 assert!(
146 world.export_path.as_ref().expect("export path").exists(),
147 "export command should write the transcript"
148 );
149 }
150
151 #[when("the user clears the active conversation")]
152 fn user_clears_active_conversation(world: &mut SessionCommandWorld) {
153 let result = execute_isolated(world, "/clear");
154 remember_result(world, &result);
155
156 assert!(!result.is_error, "clear failed: {:?}", result.message);
157 let app = world.app.as_deref().expect("app should exist");
158 assert!(
159 app.api_messages.is_empty(),
160 "clear command should remove active API messages"
161 );
162 assert_eq!(app.session.total_tokens, 0);
163 }
164
165 #[when("the user loads the saved session")]
166 fn user_loads_saved_session(world: &mut SessionCommandWorld) {
167 let save_path = world
168 .save_path
169 .as_ref()
170 .expect("save path should exist")
171 .to_string_lossy()
172 .to_string();
173 let result = execute_isolated(world, &format!("/load {save_path}"));
174 remember_result(world, &result);
175
176 assert!(!result.is_error, "load failed: {:?}", result.message);
177 world.last_message = result.message;
178 }
179
180 #[when("the user forks the active session")]
181 fn user_forks_active_session(world: &mut SessionCommandWorld) {
182 let result = execute_isolated(world, "/fork");
183 remember_result(world, &result);
184
185 assert!(!result.is_error, "fork failed: {:?}", result.message);
186 let fork_id = world
187 .app
188 .as_deref()
189 .and_then(|app| app.current_session_id.clone())
190 .expect("fork command should switch to a child session");
191 let forked = load_saved_session(world, &fork_id);
192 if world.original_session_id.is_none() {
193 world.original_session_id = forked.metadata.parent_session_id.clone();
194 }
195 world.fork_session_id = Some(fork_id);
196 }
197
198 #[when("the user tries to fork the active session")]
199 fn user_tries_to_fork_active_session(world: &mut SessionCommandWorld) {
200 let result = execute_isolated(world, "/fork");
201 remember_result(world, &result);
202 }
203
204 #[when("the user starts a new session")]
205 fn user_starts_new_session(world: &mut SessionCommandWorld) {
206 let result = execute_isolated(world, "/new");
207 remember_result(world, &result);
208
209 assert!(!result.is_error, "new session failed: {:?}", result.message);
210 let new_id = world
211 .app
212 .as_deref()
213 .and_then(|app| app.current_session_id.clone())
214 .expect("new command should set an active session id");
215 world.new_session_id = Some(new_id);
216 }
217
218 #[when(regex = r#"^the user renames the active session to "([^"]+)"$"#)]
219 fn user_renames_active_session(world: &mut SessionCommandWorld, title: String) {
220 let result = execute_isolated(world, &format!("/rename {title}"));
221 remember_result(world, &result);
222
223 assert!(!result.is_error, "rename failed: {:?}", result.message);
224 }
225
226 #[when("the user lists saved sessions")]
227 fn user_lists_saved_sessions(world: &mut SessionCommandWorld) {
228 let result = execute_isolated(world, "/sessions list");
229 remember_result(world, &result);
230
231 assert!(
232 !result.is_error,
233 "sessions list failed: {:?}",
234 result.message
235 );
236 }
237
238 #[when(regex = r#"^the user prunes sessions older than (\d+) days$"#)]
239 fn user_prunes_sessions_older_than(world: &mut SessionCommandWorld, days: String) {
240 let result = execute_isolated(world, &format!("/sessions prune {days}"));
241 remember_result(world, &result);
242
243 assert!(
244 !result.is_error,
245 "sessions prune failed: {:?}",
246 result.message
247 );
248 }
249
250 #[when("the user compacts context")]
251 fn user_compacts_context(world: &mut SessionCommandWorld) {
252 let result = execute_isolated(world, "/compact");
253 remember_result(world, &result);
254
255 assert!(!result.is_error, "compact failed: {:?}", result.message);
256 }
257
258 #[when("the user purges context")]
259 fn user_purges_context(world: &mut SessionCommandWorld) {
260 let result = execute_isolated(world, "/purge");
261 remember_result(world, &result);
262
263 assert!(!result.is_error, "purge failed: {:?}", result.message);
264 }
265
266 #[when(regex = r#"^the user prepares a session relay focused on "([^"]+)"$"#)]
267 fn user_prepares_session_relay_focused_on(world: &mut SessionCommandWorld, focus: String) {
268 let result = execute_isolated(world, &format!("/relay {focus}"));
269 remember_result(world, &result);
270
271 assert!(!result.is_error, "relay failed: {:?}", result.message);
272 }
273
274 #[when("the user runs the singular session command")]
275 fn user_runs_singular_session_command(world: &mut SessionCommandWorld) {
276 let result = execute_isolated(world, "/session");
277 remember_result(world, &result);
278 }
279
280 #[then("the active session should contain the saved message")]
281 fn active_session_contains_saved_message(world: &mut SessionCommandWorld) {
282 let app = world.app.as_deref().expect("app should exist");
283 let message = app
284 .api_messages
285 .first()
286 .expect("loaded session should have one message");
287 let content = message
288 .content
289 .iter()
290 .find_map(|block| match block {
291 ContentBlock::Text { text, .. } => Some(text.as_str()),
292 _ => None,
293 })
294 .expect("loaded message should have text content");
295
296 assert_eq!(message.role, "user");
297 assert_eq!(content, "Remember the whale migration");
298 }
299
300 #[then("the saved session file should contain the saved message")]
301 fn saved_session_file_contains_saved_message(world: &mut SessionCommandWorld) {
302 let session = read_saved_session_file(world);
303
304 assert_saved_session_contains_message(&session, "Remember the whale migration");
305 }
306
307 #[then("the load action should target the saved session file")]
308 fn load_action_targets_saved_session_file(world: &mut SessionCommandWorld) {
309 let save_path = world.save_path.as_ref().expect("save path should exist");
310
311 assert!(matches!(
312 world.last_action.as_ref(),
313 Some(AppAction::LoadSession(path)) if path == save_path
314 ));
315 }
316
317 #[then("the exported markdown should contain the active transcript")]
318 fn exported_markdown_contains_active_transcript(world: &mut SessionCommandWorld) {
319 let export_path = world
320 .export_path
321 .as_ref()
322 .expect("export path should exist");
323 let content = std::fs::read_to_string(export_path)
324 .unwrap_or_else(|err| panic!("read exported transcript {export_path:?}: {err}"));
325
326 assert!(content.contains("# Codewhale conversation export"));
327 assert!(content.contains("## 1. user"));
328 assert!(content.contains("Remember the whale migration"));
329 }
330
331 #[then("CodeWhale should defer the session-loaded receipt to the event loop")]
332 fn codewhale_defers_session_loaded_receipt(world: &mut SessionCommandWorld) {
333 assert_eq!(
334 world.last_message, None,
335 "the command layer must not report success before the event loop applies the load action"
336 );
337 }
338
339 #[then("the forked session should reference the original session")]
340 fn forked_session_references_original_session(world: &mut SessionCommandWorld) {
341 let original_id = world
342 .original_session_id
343 .as_deref()
344 .expect("original session id should exist");
345 let fork_id = world
346 .fork_session_id
347 .as_deref()
348 .expect("fork session id should exist");
349 let forked = load_saved_session(world, fork_id);
350
351 assert_eq!(
352 forked.metadata.parent_session_id.as_deref(),
353 Some(original_id)
354 );
355 assert_eq!(forked.metadata.forked_from_message_count, Some(1));
356 }
357
358 #[then("the original session should still be loadable")]
359 fn original_session_still_loadable(world: &mut SessionCommandWorld) {
360 let original_id = world
361 .original_session_id
362 .as_deref()
363 .expect("original session id should exist");
364 let original = load_saved_session(world, original_id);
365
366 assert_saved_session_contains_message(&original, "Remember the whale migration");
367 }
368
369 #[then("the active session should be the forked session")]
370 fn active_session_is_forked_session(world: &mut SessionCommandWorld) {
371 let fork_id = world
372 .fork_session_id
373 .as_deref()
374 .expect("fork session id should exist");
375 let app = world.app.as_deref().expect("app should exist");
376
377 assert_eq!(app.current_session_id.as_deref(), Some(fork_id));
378 assert_app_contains_message(app, "Remember the whale migration");
379 }
380
381 #[then("CodeWhale should reject the fork because there are no messages")]
382 fn codewhale_rejects_empty_fork(world: &mut SessionCommandWorld) {
383 assert_eq!(
384 world.last_result_is_error,
385 Some(true),
386 "last command should have failed"
387 );
388 let message = world
389 .last_message
390 .as_deref()
391 .expect("fork rejection should include a message");
392
393 assert!(
394 message.contains("Nothing to fork"),
395 "unexpected fork rejection message: {message}"
396 );
397 }
398
399 #[then("the active session should be empty")]
400 fn active_session_empty(world: &mut SessionCommandWorld) {
401 let app = world.app.as_deref().expect("app should exist");
402
403 assert!(app.api_messages.is_empty());
404 assert_eq!(app.session.total_tokens, 0);
405 assert_eq!(app.session.total_conversation_tokens, 0);
406 }
407
408 #[then("the original and forked sessions should remain loadable")]
409 fn original_and_forked_sessions_remain_loadable(world: &mut SessionCommandWorld) {
410 let original_id = world
411 .original_session_id
412 .as_deref()
413 .expect("original session id should exist");
414 let fork_id = world
415 .fork_session_id
416 .as_deref()
417 .expect("fork session id should exist");
418 let original = load_saved_session(world, original_id);
419 let forked = load_saved_session(world, fork_id);
420
421 assert_saved_session_contains_message(&original, "Remember the whale migration");
422 assert_saved_session_contains_message(&forked, "Remember the whale migration");
423 assert_eq!(
424 forked.metadata.parent_session_id.as_deref(),
425 Some(original_id)
426 );
427 }
428
429 #[then("the active session should be a new empty session")]
430 fn active_session_is_new_empty_session(world: &mut SessionCommandWorld) {
431 let original_id = world
432 .original_session_id
433 .as_deref()
434 .expect("original session id should exist");
435 let fork_id = world
436 .fork_session_id
437 .as_deref()
438 .expect("fork session id should exist");
439 let new_id = world
440 .new_session_id
441 .as_deref()
442 .expect("new session id should exist");
443 let app = world.app.as_deref().expect("app should exist");
444
445 assert_eq!(app.current_session_id.as_deref(), Some(new_id));
446 assert_ne!(new_id, original_id);
447 assert_ne!(new_id, fork_id);
448 assert!(app.api_messages.is_empty());
449 assert_eq!(app.session.total_tokens, 0);
450 }
451
452 #[then("the active session should be cleared into a fresh empty session")]
453 fn active_session_cleared_into_fresh_empty_session(world: &mut SessionCommandWorld) {
454 let app = world.app.as_deref().expect("app should exist");
455
456 // `/clear` claims the next session id in the App (like `/new`) so the
457 // engine, the turn-start checkpoint, and autosave all share one id.
458 let cleared_id = app
459 .current_session_id
460 .as_deref()
461 .expect("/clear should claim a fresh session id");
462 assert!(
463 uuid::Uuid::parse_str(cleared_id).is_ok(),
464 "fresh session id should be a uuid, got {cleared_id:?}"
465 );
466 for previous in [
467 world.original_session_id.as_deref(),
468 world.fork_session_id.as_deref(),
469 ]
470 .into_iter()
471 .flatten()
472 {
473 assert_ne!(
474 cleared_id, previous,
475 "/clear must not reuse a saved session id"
476 );
477 }
478 // When `/clear` was the last command its SyncSession must carry the same
479 // id; a later `/load` in the same scenario leaves a different action.
480 if let Some(AppAction::SyncSession {
481 session_id,
482 messages,
483 ..
484 }) = world.last_action.as_ref()
485 {
486 assert_eq!(session_id.as_deref(), Some(cleared_id));
487 assert!(messages.is_empty());
488 }
489 assert!(app.api_messages.is_empty());
490 assert_eq!(app.session.total_tokens, 0);
491 }
492
493 #[then(regex = r#"^the active saved session title should be "([^"]+)"$"#)]
494 fn active_saved_session_title_should_be(world: &mut SessionCommandWorld, expected: String) {
495 let app = world.app.as_deref().expect("app should exist");
496 let session_id = app
497 .current_session_id
498 .as_deref()
499 .expect("active session id should exist");
500 let saved = load_saved_session(world, session_id);
501
502 assert_eq!(saved.metadata.title, expected);
503 }
504
505 #[then("the active session should be the original session")]
506 fn active_session_is_original_session(world: &mut SessionCommandWorld) {
507 let original_id = world
508 .original_session_id
509 .as_deref()
510 .expect("original session id should exist");
511 let app = world.app.as_deref().expect("app should exist");
512
513 assert_eq!(app.current_session_id.as_deref(), Some(original_id));
514 assert_app_contains_message(app, "Remember the whale migration");
515 }
516
517 #[then("the session picker should be open")]
518 fn session_picker_should_be_open(world: &mut SessionCommandWorld) {
519 let app = world.app.as_deref().expect("app should exist");
520
521 assert_eq!(app.view_stack.top_kind(), Some(ModalKind::SessionPicker));
522 }
523
524 #[then("CodeWhale should report that one session was pruned")]
525 fn codewhale_reports_one_session_pruned(world: &mut SessionCommandWorld) {
526 let message = world
527 .last_message
528 .as_deref()
529 .expect("prune command should produce a message");
530
531 assert!(
532 message.contains("pruned 1 session"),
533 "unexpected prune message: {message}"
534 );
535 }
536
537 #[then("the fresh session should still be loadable")]
538 fn fresh_session_still_loadable(world: &mut SessionCommandWorld) {
539 let fresh_id = world
540 .fresh_session_id
541 .as_deref()
542 .expect("fresh session id should exist");
543 let fresh = load_saved_session(world, fresh_id);
544
545 assert_eq!(fresh.metadata.title, "Fresh session");
546 }
547
548 #[then("the stale session should no longer be loadable")]
549 fn stale_session_no_longer_loadable(world: &mut SessionCommandWorld) {
550 let stale_id = world
551 .stale_session_id
552 .as_deref()
553 .expect("stale session id should exist");
554
555 assert!(
556 try_load_saved_session(world, stale_id).is_err(),
557 "stale session should have been pruned"
558 );
559 }
560
561 #[then("CodeWhale should trigger context compaction")]
562 fn codewhale_triggers_context_compaction(world: &mut SessionCommandWorld) {
563 assert_eq!(
564 world.last_result_is_error,
565 Some(false),
566 "compact command should succeed"
567 );
568 assert!(matches!(
569 world.last_action.as_ref(),
570 Some(AppAction::CompactContext { .. })
571 ));
572 assert_eq!(world.last_message.as_deref(), Some("Making room…"));
573 }
574
575 #[then("CodeWhale should trigger context purge")]
576 fn codewhale_triggers_context_purge(world: &mut SessionCommandWorld) {
577 assert_eq!(
578 world.last_result_is_error,
579 Some(false),
580 "purge command should succeed"
581 );
582 assert!(matches!(
583 world.last_action.as_ref(),
584 Some(AppAction::PurgeContext)
585 ));
586 assert_eq!(
587 world.last_message.as_deref(),
588 Some("Agent context purge triggered...")
589 );
590 }
591
592 #[then(regex = r#"^CodeWhale should send a session relay instruction focused on "([^"]+)"$"#)]
593 fn codewhale_sends_session_relay_instruction_focused_on(
594 world: &mut SessionCommandWorld,
595 focus: String,
596 ) {
597 assert_eq!(
598 world.last_result_is_error,
599 Some(false),
600 "relay command should succeed"
601 );
602 let message = match world.last_action.as_ref() {
603 Some(AppAction::SendMessage(message)) => message,
604 other => panic!("expected relay SendMessage action, got {other:?}"),
605 };
606
607 assert!(message.contains("Write or update `.deepseek/handoff.md`."));
608 assert!(message.contains("# Session relay"));
609 assert!(message.contains("## Verification"));
610 assert!(
611 message.contains(&format!("- Requested relay focus: {focus}")),
612 "relay instruction should include requested focus: {message}"
613 );
614 assert_eq!(
615 world.last_message.as_deref(),
616 Some("Preparing session relay at .deepseek/handoff.md...")
617 );
618 }
619
620 #[then("CodeWhale should reject the unknown session command")]
621 fn codewhale_rejects_unknown_session_command(world: &mut SessionCommandWorld) {
622 assert_eq!(
623 world.last_result_is_error,
624 Some(true),
625 "singular /session should be rejected"
626 );
627 let message = world
628 .last_message
629 .as_deref()
630 .expect("unknown command should include a message");
631
632 assert!(
633 message.contains("Unknown command: /session"),
634 "unexpected unknown command message: {message}"
635 );
636 assert!(
637 message.contains("/sessions") || message.contains("/save"),
638 "unknown command should include a session-related suggestion: {message}"
639 );
640 }
641
642 /// C01-09: `/save <path>` replaces a saved session, never an arbitrary file.
643 #[tokio::test(flavor = "current_thread")]
644 async fn save_refuses_to_overwrite_a_file_that_is_not_a_saved_session() {
645 let mut world = SessionCommandWorld::default();
646 workspace_with_one_user_message(&mut world);
647 let target = world
648 .tmpdir
649 .as_ref()
650 .expect("tmpdir")
651 .path()
652 .join("notes.txt");
653 std::fs::write(&target, "keep me").expect("seed an unrelated file");
654
655 let refused = execute_isolated(&mut world, &format!("/save {}", target.display()));
656 assert!(refused.is_error, "{:?}", refused.message);
657 assert_eq!(
658 std::fs::read_to_string(&target).expect("still readable"),
659 "keep me",
660 "the unrelated file is untouched"
661 );
662
663 let saved = world.save_path.clone().expect("save path");
664 for attempt in ["first save", "re-save over the saved session"] {
665 let result = execute_isolated(&mut world, &format!("/save {}", saved.display()));
666 assert!(!result.is_error, "{attempt}: {:?}", result.message);
667 }
668 }
669
670 #[tokio::test(flavor = "current_thread")]
671 async fn save_export_and_load_session_workflow() {
672 run_scenario(SAVE_LOAD_SCENARIO, 10).await;
673 }
674
675 #[tokio::test(flavor = "current_thread")]
676 async fn fork_keeps_original_session_resumable() {
677 run_scenario(FORK_RESUMABLE_SCENARIO, 5).await;
678 }
679
680 #[tokio::test(flavor = "current_thread")]
681 async fn new_session_cannot_be_forked_before_messages_exist() {
682 run_scenario(NEW_THEN_FORK_SCENARIO, 5).await;
683 }
684
685 #[tokio::test(flavor = "current_thread")]
686 async fn cleared_session_cannot_be_forked_before_messages_exist() {
687 run_scenario(CLEAR_THEN_FORK_SCENARIO, 5).await;
688 }
689
690 #[tokio::test(flavor = "current_thread")]
691 async fn fork_followed_by_new_keeps_both_saved_sessions() {
692 run_scenario(FORK_THEN_NEW_SCENARIO, 5).await;
693 }
694
695 #[tokio::test(flavor = "current_thread")]
696 async fn fork_followed_by_clear_keeps_both_saved_sessions() {
697 run_scenario(FORK_THEN_CLEAR_SCENARIO, 5).await;
698 }
699
700 #[tokio::test(flavor = "current_thread")]
701 async fn rename_updates_active_saved_session_title() {
702 run_scenario(RENAME_SCENARIO, 4).await;
703 }
704
705 #[tokio::test(flavor = "current_thread")]
706 async fn sessions_list_opens_saved_session_picker() {
707 run_scenario(SESSIONS_LIST_SCENARIO, 4).await;
708 }
709
710 #[tokio::test(flavor = "current_thread")]
711 async fn sessions_prune_removes_only_stale_sessions() {
712 run_scenario(SESSIONS_PRUNE_SCENARIO, 5).await;
713 }
714
715 #[tokio::test(flavor = "current_thread")]
716 async fn context_management_commands_emit_actions_without_clearing_active_session() {
717 run_scenario(CONTEXT_MANAGEMENT_SCENARIO, 10).await;
718 }
719
720 #[tokio::test(flavor = "current_thread")]
721 async fn singular_session_command_is_not_registered() {
722 run_scenario(SINGULAR_SESSION_SCENARIO, 4).await;
723 }
724
725 async fn run_scenario(name: &'static str, expected_steps: usize) {
726 let writer = SessionCommandWorld::cucumber()
727 .fail_on_skipped()
728 .with_default_cli()
729 .filter_run(FEATURE_PATH, move |feature, _, scenario| {
730 feature.name == FEATURE_NAME && scenario.name == name
731 })
732 .await;
733 assert_eq!(writer.failed_steps(), 0, "scenario failed: {name}");
734 assert_eq!(writer.skipped_steps(), 0, "scenario skipped steps: {name}");
735 assert_eq!(
736 writer.passed_steps(),
737 expected_steps,
738 "scenario did not run: {name}"
739 );
740 }
741
742 fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App {
743 let options = TuiOptions {
744 skills_dir: tmpdir.path().join("skills"),
745 memory_path: tmpdir.path().join("memory.md"),
746 notes_path: tmpdir.path().join("notes.txt"),
747 mcp_config_path: tmpdir.path().join("mcp.json"),
748 ..crate::test_support::test_tui_options(tmpdir.path())
749 };
750 App::new(options, &Config::default())
751 }
752
753 fn execute_isolated(world: &mut SessionCommandWorld, command: &str) -> CommandResult {
754 let home = world
755 .home_path
756 .as_ref()
757 .expect("test home should exist")
758 .clone();
759 std::fs::create_dir_all(&home).expect("create isolated test home");
760
761 let _lock = lock_test_env();
762 let _home = EnvVarGuard::set("HOME", &home);
763 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", home.join(".codewhale"));
764
765 let app = world.app.as_deref_mut().expect("app should exist");
766 commands::user_registry::reload(Some(&app.workspace));
767 commands::execute(command, app)
768 }
769
770 fn remember_result(world: &mut SessionCommandWorld, result: &CommandResult) {
771 world.last_result_is_error = Some(result.is_error);
772 world.last_message = result.message.clone();
773 world.last_action = result.action.clone();
774 }
775
776 fn persist_active_session(world: &SessionCommandWorld) {
777 let app = world.app.as_deref().expect("app should exist");
778 let session_id = app
779 .current_session_id
780 .as_ref()
781 .expect("active session id should exist")
782 .clone();
783 let session = create_saved_session_with_id_and_mode(
784 session_id,
785 &app.api_messages,
786 &app.model,
787 &app.workspace,
788 u64::from(app.session.total_tokens),
789 app.system_prompt.as_ref(),
790 Some(app.mode.label()),
791 );
792 let home = world
793 .home_path
794 .as_ref()
795 .expect("test home should exist")
796 .clone();
797 std::fs::create_dir_all(&home).expect("create isolated test home");
798
799 let _lock = lock_test_env();
800 let _home = EnvVarGuard::set("HOME", &home);
801 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", home.join(".codewhale"));
802 let manager = SessionManager::default_location().expect("open isolated session manager");
803
804 manager
805 .save_session(&session)
806 .expect("persist active session");
807 }
808
809 fn persist_session_with_age(world: &SessionCommandWorld, session_id: &str, title: &str, days: i64) {
810 let app = world.app.as_deref().expect("app should exist");
811 let mut session = create_saved_session_with_id_and_mode(
812 session_id.to_string(),
813 &app.api_messages,
814 &app.model,
815 &app.workspace,
816 u64::from(app.session.total_tokens),
817 app.system_prompt.as_ref(),
818 Some(app.mode.label()),
819 );
820 let timestamp = Utc::now() - ChronoDuration::days(days);
821 session.metadata.title = title.to_string();
822 session.metadata.created_at = timestamp;
823 session.metadata.updated_at = timestamp;
824
825 let home = world
826 .home_path
827 .as_ref()
828 .expect("test home should exist")
829 .clone();
830 std::fs::create_dir_all(&home).expect("create isolated test home");
831
832 let _lock = lock_test_env();
833 let _home = EnvVarGuard::set("HOME", &home);
834 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", home.join(".codewhale"));
835 let manager = SessionManager::default_location().expect("open isolated session manager");
836
837 manager.save_session(&session).expect("persist session");
838 }
839
840 fn load_saved_session(world: &SessionCommandWorld, session_id: &str) -> SavedSession {
841 try_load_saved_session(world, session_id)
842 .unwrap_or_else(|_| panic!("load saved session failed"))
843 }
844
845 fn try_load_saved_session(
846 world: &SessionCommandWorld,
847 session_id: &str,
848 ) -> std::io::Result<SavedSession> {
849 let home = world
850 .home_path
851 .as_ref()
852 .expect("test home should exist")
853 .clone();
854 std::fs::create_dir_all(&home).expect("create isolated test home");
855
856 let _lock = lock_test_env();
857 let _home = EnvVarGuard::set("HOME", &home);
858 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", home.join(".codewhale"));
859 let manager = SessionManager::default_location().expect("open isolated session manager");
860
861 manager.load_session(session_id)
862 }
863
864 fn read_saved_session_file(world: &SessionCommandWorld) -> SavedSession {
865 let save_path = world.save_path.as_ref().expect("save path should exist");
866 let content = std::fs::read_to_string(save_path)
867 .unwrap_or_else(|err| panic!("read saved session file {save_path:?}: {err}"));
868
869 serde_json::from_str(&content)
870 .unwrap_or_else(|err| panic!("parse saved session file {save_path:?}: {err}"))
871 }
872
873 fn assert_app_contains_message(app: &App, expected: &str) {
874 let message = app
875 .api_messages
876 .first()
877 .expect("active session should contain one message");
878 let content = message
879 .content
880 .iter()
881 .find_map(text_content)
882 .expect("active message should contain text");
883
884 assert_eq!(message.role, "user");
885 assert_eq!(content, expected);
886 }
887
888 fn assert_saved_session_contains_message(session: &SavedSession, expected: &str) {
889 let message = session
890 .messages
891 .first()
892 .expect("saved session should contain one message");
893 let content = message
894 .content
895 .iter()
896 .find_map(text_content)
897 .expect("saved message should contain text");
898
899 assert_eq!(message.role, "user");
900 assert_eq!(content, expected);
901 }
902
903 fn text_content(block: &ContentBlock) -> Option<&str> {
904 match block {
905 ContentBlock::Text { text, .. } => Some(text.as_str()),
906 _ => None,
907 }
908 }
909
909 lines RUST