返回 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!(
573 world.last_message.as_deref(),
574 Some("Context compaction triggered...")
575 );
576 }
577
578 #[then("CodeWhale should trigger context purge")]
579 fn codewhale_triggers_context_purge(world: &mut SessionCommandWorld) {
580 assert_eq!(
581 world.last_result_is_error,
582 Some(false),
583 "purge command should succeed"
584 );
585 assert!(matches!(
586 world.last_action.as_ref(),
587 Some(AppAction::PurgeContext)
588 ));
589 assert_eq!(
590 world.last_message.as_deref(),
591 Some("Agent context purge triggered...")
592 );
593 }
594
595 #[then(regex = r#"^CodeWhale should send a session relay instruction focused on "([^"]+)"$"#)]
596 fn codewhale_sends_session_relay_instruction_focused_on(
597 world: &mut SessionCommandWorld,
598 focus: String,
599 ) {
600 assert_eq!(
601 world.last_result_is_error,
602 Some(false),
603 "relay command should succeed"
604 );
605 let message = match world.last_action.as_ref() {
606 Some(AppAction::SendMessage(message)) => message,
607 other => panic!("expected relay SendMessage action, got {other:?}"),
608 };
609
610 assert!(message.contains("Write or update `.deepseek/handoff.md`."));
611 assert!(message.contains("# Session relay"));
612 assert!(message.contains("## Verification"));
613 assert!(
614 message.contains(&format!("- Requested relay focus: {focus}")),
615 "relay instruction should include requested focus: {message}"
616 );
617 assert_eq!(
618 world.last_message.as_deref(),
619 Some("Preparing session relay at .deepseek/handoff.md...")
620 );
621 }
622
623 #[then("CodeWhale should reject the unknown session command")]
624 fn codewhale_rejects_unknown_session_command(world: &mut SessionCommandWorld) {
625 assert_eq!(
626 world.last_result_is_error,
627 Some(true),
628 "singular /session should be rejected"
629 );
630 let message = world
631 .last_message
632 .as_deref()
633 .expect("unknown command should include a message");
634
635 assert!(
636 message.contains("Unknown command: /session"),
637 "unexpected unknown command message: {message}"
638 );
639 assert!(
640 message.contains("/sessions") || message.contains("/save"),
641 "unknown command should include a session-related suggestion: {message}"
642 );
643 }
644
645 #[tokio::test(flavor = "current_thread")]
646 async fn save_export_and_load_session_workflow() {
647 run_scenario(SAVE_LOAD_SCENARIO, 10).await;
648 }
649
650 #[tokio::test(flavor = "current_thread")]
651 async fn fork_keeps_original_session_resumable() {
652 run_scenario(FORK_RESUMABLE_SCENARIO, 5).await;
653 }
654
655 #[tokio::test(flavor = "current_thread")]
656 async fn new_session_cannot_be_forked_before_messages_exist() {
657 run_scenario(NEW_THEN_FORK_SCENARIO, 5).await;
658 }
659
660 #[tokio::test(flavor = "current_thread")]
661 async fn cleared_session_cannot_be_forked_before_messages_exist() {
662 run_scenario(CLEAR_THEN_FORK_SCENARIO, 5).await;
663 }
664
665 #[tokio::test(flavor = "current_thread")]
666 async fn fork_followed_by_new_keeps_both_saved_sessions() {
667 run_scenario(FORK_THEN_NEW_SCENARIO, 5).await;
668 }
669
670 #[tokio::test(flavor = "current_thread")]
671 async fn fork_followed_by_clear_keeps_both_saved_sessions() {
672 run_scenario(FORK_THEN_CLEAR_SCENARIO, 5).await;
673 }
674
675 #[tokio::test(flavor = "current_thread")]
676 async fn rename_updates_active_saved_session_title() {
677 run_scenario(RENAME_SCENARIO, 4).await;
678 }
679
680 #[tokio::test(flavor = "current_thread")]
681 async fn sessions_list_opens_saved_session_picker() {
682 run_scenario(SESSIONS_LIST_SCENARIO, 4).await;
683 }
684
685 #[tokio::test(flavor = "current_thread")]
686 async fn sessions_prune_removes_only_stale_sessions() {
687 run_scenario(SESSIONS_PRUNE_SCENARIO, 5).await;
688 }
689
690 #[tokio::test(flavor = "current_thread")]
691 async fn context_management_commands_emit_actions_without_clearing_active_session() {
692 run_scenario(CONTEXT_MANAGEMENT_SCENARIO, 10).await;
693 }
694
695 #[tokio::test(flavor = "current_thread")]
696 async fn singular_session_command_is_not_registered() {
697 run_scenario(SINGULAR_SESSION_SCENARIO, 4).await;
698 }
699
700 async fn run_scenario(name: &'static str, expected_steps: usize) {
701 let writer = SessionCommandWorld::cucumber()
702 .fail_on_skipped()
703 .with_default_cli()
704 .filter_run(FEATURE_PATH, move |feature, _, scenario| {
705 feature.name == FEATURE_NAME && scenario.name == name
706 })
707 .await;
708 assert_eq!(writer.failed_steps(), 0, "scenario failed: {name}");
709 assert_eq!(writer.skipped_steps(), 0, "scenario skipped steps: {name}");
710 assert_eq!(
711 writer.passed_steps(),
712 expected_steps,
713 "scenario did not run: {name}"
714 );
715 }
716
717 fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App {
718 let options = TuiOptions {
719 skills_dir: tmpdir.path().join("skills"),
720 memory_path: tmpdir.path().join("memory.md"),
721 notes_path: tmpdir.path().join("notes.txt"),
722 mcp_config_path: tmpdir.path().join("mcp.json"),
723 ..crate::test_support::test_tui_options(tmpdir.path())
724 };
725 App::new(options, &Config::default())
726 }
727
728 fn execute_isolated(world: &mut SessionCommandWorld, command: &str) -> CommandResult {
729 let home = world
730 .home_path
731 .as_ref()
732 .expect("test home should exist")
733 .clone();
734 std::fs::create_dir_all(&home).expect("create isolated test home");
735
736 let _lock = lock_test_env();
737 let _home = EnvVarGuard::set("HOME", &home);
738 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", home.join(".codewhale"));
739
740 let app = world.app.as_deref_mut().expect("app should exist");
741 commands::user_registry::reload(Some(&app.workspace));
742 commands::execute(command, app)
743 }
744
745 fn remember_result(world: &mut SessionCommandWorld, result: &CommandResult) {
746 world.last_result_is_error = Some(result.is_error);
747 world.last_message = result.message.clone();
748 world.last_action = result.action.clone();
749 }
750
751 fn persist_active_session(world: &SessionCommandWorld) {
752 let app = world.app.as_deref().expect("app should exist");
753 let session_id = app
754 .current_session_id
755 .as_ref()
756 .expect("active session id should exist")
757 .clone();
758 let session = create_saved_session_with_id_and_mode(
759 session_id,
760 &app.api_messages,
761 &app.model,
762 &app.workspace,
763 u64::from(app.session.total_tokens),
764 app.system_prompt.as_ref(),
765 Some(app.mode.label()),
766 );
767 let home = world
768 .home_path
769 .as_ref()
770 .expect("test home should exist")
771 .clone();
772 std::fs::create_dir_all(&home).expect("create isolated test home");
773
774 let _lock = lock_test_env();
775 let _home = EnvVarGuard::set("HOME", &home);
776 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", home.join(".codewhale"));
777 let manager = SessionManager::default_location().expect("open isolated session manager");
778
779 manager
780 .save_session(&session)
781 .expect("persist active session");
782 }
783
784 fn persist_session_with_age(world: &SessionCommandWorld, session_id: &str, title: &str, days: i64) {
785 let app = world.app.as_deref().expect("app should exist");
786 let mut session = create_saved_session_with_id_and_mode(
787 session_id.to_string(),
788 &app.api_messages,
789 &app.model,
790 &app.workspace,
791 u64::from(app.session.total_tokens),
792 app.system_prompt.as_ref(),
793 Some(app.mode.label()),
794 );
795 let timestamp = Utc::now() - ChronoDuration::days(days);
796 session.metadata.title = title.to_string();
797 session.metadata.created_at = timestamp;
798 session.metadata.updated_at = timestamp;
799
800 let home = world
801 .home_path
802 .as_ref()
803 .expect("test home should exist")
804 .clone();
805 std::fs::create_dir_all(&home).expect("create isolated test home");
806
807 let _lock = lock_test_env();
808 let _home = EnvVarGuard::set("HOME", &home);
809 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", home.join(".codewhale"));
810 let manager = SessionManager::default_location().expect("open isolated session manager");
811
812 manager.save_session(&session).expect("persist session");
813 }
814
815 fn load_saved_session(world: &SessionCommandWorld, session_id: &str) -> SavedSession {
816 try_load_saved_session(world, session_id)
817 .unwrap_or_else(|_| panic!("load saved session failed"))
818 }
819
820 fn try_load_saved_session(
821 world: &SessionCommandWorld,
822 session_id: &str,
823 ) -> std::io::Result<SavedSession> {
824 let home = world
825 .home_path
826 .as_ref()
827 .expect("test home should exist")
828 .clone();
829 std::fs::create_dir_all(&home).expect("create isolated test home");
830
831 let _lock = lock_test_env();
832 let _home = EnvVarGuard::set("HOME", &home);
833 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", home.join(".codewhale"));
834 let manager = SessionManager::default_location().expect("open isolated session manager");
835
836 manager.load_session(session_id)
837 }
838
839 fn read_saved_session_file(world: &SessionCommandWorld) -> SavedSession {
840 let save_path = world.save_path.as_ref().expect("save path should exist");
841 let content = std::fs::read_to_string(save_path)
842 .unwrap_or_else(|err| panic!("read saved session file {save_path:?}: {err}"));
843
844 serde_json::from_str(&content)
845 .unwrap_or_else(|err| panic!("parse saved session file {save_path:?}: {err}"))
846 }
847
848 fn assert_app_contains_message(app: &App, expected: &str) {
849 let message = app
850 .api_messages
851 .first()
852 .expect("active session should contain one message");
853 let content = message
854 .content
855 .iter()
856 .find_map(text_content)
857 .expect("active message should contain text");
858
859 assert_eq!(message.role, "user");
860 assert_eq!(content, expected);
861 }
862
863 fn assert_saved_session_contains_message(session: &SavedSession, expected: &str) {
864 let message = session
865 .messages
866 .first()
867 .expect("saved session should contain one message");
868 let content = message
869 .content
870 .iter()
871 .find_map(text_content)
872 .expect("saved message should contain text");
873
874 assert_eq!(message.role, "user");
875 assert_eq!(content, expected);
876 }
877
878 fn text_content(block: &ContentBlock) -> Option<&str> {
879 match block {
880 ContentBlock::Text { text, .. } => Some(text.as_str()),
881 _ => None,
882 }
883 }
884
884 lines RUST