返回 CodeWhale
acceptance.rs
根目录 / crates / tui / src / commands / groups / 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::models::{ContentBlock, Message};
12 use crate::session_manager::{SavedSession, SessionManager, create_saved_session_with_id_and_mode};
13 use crate::test_support::{EnvVarGuard, lock_test_env};
14 use crate::tui::app::{App, AppAction, TuiOptions};
15 use crate::tui::history::HistoryCell;
16 use crate::tui::views::ModalKind;
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.push(Message {
77 role: "user".to_string(),
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 without an active session id")]
453 fn active_session_cleared_without_active_session_id(world: &mut SessionCommandWorld) {
454 let app = world.app.as_deref().expect("app should exist");
455
456 assert!(app.current_session_id.is_none());
457 assert!(app.api_messages.is_empty());
458 assert_eq!(app.session.total_tokens, 0);
459 }
460
461 #[then(regex = r#"^the active saved session title should be "([^"]+)"$"#)]
462 fn active_saved_session_title_should_be(world: &mut SessionCommandWorld, expected: String) {
463 let app = world.app.as_deref().expect("app should exist");
464 let session_id = app
465 .current_session_id
466 .as_deref()
467 .expect("active session id should exist");
468 let saved = load_saved_session(world, session_id);
469
470 assert_eq!(saved.metadata.title, expected);
471 }
472
473 #[then("the active session should be the original session")]
474 fn active_session_is_original_session(world: &mut SessionCommandWorld) {
475 let original_id = world
476 .original_session_id
477 .as_deref()
478 .expect("original session id should exist");
479 let app = world.app.as_deref().expect("app should exist");
480
481 assert_eq!(app.current_session_id.as_deref(), Some(original_id));
482 assert_app_contains_message(app, "Remember the whale migration");
483 }
484
485 #[then("the session picker should be open")]
486 fn session_picker_should_be_open(world: &mut SessionCommandWorld) {
487 let app = world.app.as_deref().expect("app should exist");
488
489 assert_eq!(app.view_stack.top_kind(), Some(ModalKind::SessionPicker));
490 }
491
492 #[then("CodeWhale should report that one session was pruned")]
493 fn codewhale_reports_one_session_pruned(world: &mut SessionCommandWorld) {
494 let message = world
495 .last_message
496 .as_deref()
497 .expect("prune command should produce a message");
498
499 assert!(
500 message.contains("pruned 1 session"),
501 "unexpected prune message: {message}"
502 );
503 }
504
505 #[then("the fresh session should still be loadable")]
506 fn fresh_session_still_loadable(world: &mut SessionCommandWorld) {
507 let fresh_id = world
508 .fresh_session_id
509 .as_deref()
510 .expect("fresh session id should exist");
511 let fresh = load_saved_session(world, fresh_id);
512
513 assert_eq!(fresh.metadata.title, "Fresh session");
514 }
515
516 #[then("the stale session should no longer be loadable")]
517 fn stale_session_no_longer_loadable(world: &mut SessionCommandWorld) {
518 let stale_id = world
519 .stale_session_id
520 .as_deref()
521 .expect("stale session id should exist");
522
523 assert!(
524 try_load_saved_session(world, stale_id).is_err(),
525 "stale session should have been pruned"
526 );
527 }
528
529 #[then("CodeWhale should trigger context compaction")]
530 fn codewhale_triggers_context_compaction(world: &mut SessionCommandWorld) {
531 assert_eq!(
532 world.last_result_is_error,
533 Some(false),
534 "compact command should succeed"
535 );
536 assert!(matches!(
537 world.last_action.as_ref(),
538 Some(AppAction::CompactContext { .. })
539 ));
540 assert_eq!(
541 world.last_message.as_deref(),
542 Some("Context compaction triggered...")
543 );
544 }
545
546 #[then("CodeWhale should trigger context purge")]
547 fn codewhale_triggers_context_purge(world: &mut SessionCommandWorld) {
548 assert_eq!(
549 world.last_result_is_error,
550 Some(false),
551 "purge command should succeed"
552 );
553 assert!(matches!(
554 world.last_action.as_ref(),
555 Some(AppAction::PurgeContext)
556 ));
557 assert_eq!(
558 world.last_message.as_deref(),
559 Some("Agent context purge triggered...")
560 );
561 }
562
563 #[then(regex = r#"^CodeWhale should send a session relay instruction focused on "([^"]+)"$"#)]
564 fn codewhale_sends_session_relay_instruction_focused_on(
565 world: &mut SessionCommandWorld,
566 focus: String,
567 ) {
568 assert_eq!(
569 world.last_result_is_error,
570 Some(false),
571 "relay command should succeed"
572 );
573 let message = match world.last_action.as_ref() {
574 Some(AppAction::SendMessage(message)) => message,
575 other => panic!("expected relay SendMessage action, got {other:?}"),
576 };
577
578 assert!(message.contains("Write or update `.deepseek/handoff.md`."));
579 assert!(message.contains("# Session relay"));
580 assert!(message.contains("## Verification"));
581 assert!(
582 message.contains(&format!("- Requested relay focus: {focus}")),
583 "relay instruction should include requested focus: {message}"
584 );
585 assert_eq!(
586 world.last_message.as_deref(),
587 Some("Preparing session relay at .deepseek/handoff.md...")
588 );
589 }
590
591 #[then("CodeWhale should reject the unknown session command")]
592 fn codewhale_rejects_unknown_session_command(world: &mut SessionCommandWorld) {
593 assert_eq!(
594 world.last_result_is_error,
595 Some(true),
596 "singular /session should be rejected"
597 );
598 let message = world
599 .last_message
600 .as_deref()
601 .expect("unknown command should include a message");
602
603 assert!(
604 message.contains("Unknown command: /session"),
605 "unexpected unknown command message: {message}"
606 );
607 assert!(
608 message.contains("/sessions") || message.contains("/save"),
609 "unknown command should include a session-related suggestion: {message}"
610 );
611 }
612
613 #[tokio::test(flavor = "current_thread")]
614 async fn save_export_and_load_session_workflow() {
615 run_scenario(SAVE_LOAD_SCENARIO, 10).await;
616 }
617
618 #[tokio::test(flavor = "current_thread")]
619 async fn fork_keeps_original_session_resumable() {
620 run_scenario(FORK_RESUMABLE_SCENARIO, 5).await;
621 }
622
623 #[tokio::test(flavor = "current_thread")]
624 async fn new_session_cannot_be_forked_before_messages_exist() {
625 run_scenario(NEW_THEN_FORK_SCENARIO, 5).await;
626 }
627
628 #[tokio::test(flavor = "current_thread")]
629 async fn cleared_session_cannot_be_forked_before_messages_exist() {
630 run_scenario(CLEAR_THEN_FORK_SCENARIO, 5).await;
631 }
632
633 #[tokio::test(flavor = "current_thread")]
634 async fn fork_followed_by_new_keeps_both_saved_sessions() {
635 run_scenario(FORK_THEN_NEW_SCENARIO, 5).await;
636 }
637
638 #[tokio::test(flavor = "current_thread")]
639 async fn fork_followed_by_clear_keeps_both_saved_sessions() {
640 run_scenario(FORK_THEN_CLEAR_SCENARIO, 5).await;
641 }
642
643 #[tokio::test(flavor = "current_thread")]
644 async fn rename_updates_active_saved_session_title() {
645 run_scenario(RENAME_SCENARIO, 4).await;
646 }
647
648 #[tokio::test(flavor = "current_thread")]
649 async fn sessions_list_opens_saved_session_picker() {
650 run_scenario(SESSIONS_LIST_SCENARIO, 4).await;
651 }
652
653 #[tokio::test(flavor = "current_thread")]
654 async fn sessions_prune_removes_only_stale_sessions() {
655 run_scenario(SESSIONS_PRUNE_SCENARIO, 5).await;
656 }
657
658 #[tokio::test(flavor = "current_thread")]
659 async fn context_management_commands_emit_actions_without_clearing_active_session() {
660 run_scenario(CONTEXT_MANAGEMENT_SCENARIO, 10).await;
661 }
662
663 #[tokio::test(flavor = "current_thread")]
664 async fn singular_session_command_is_not_registered() {
665 run_scenario(SINGULAR_SESSION_SCENARIO, 4).await;
666 }
667
668 async fn run_scenario(name: &'static str, expected_steps: usize) {
669 let writer = SessionCommandWorld::cucumber()
670 .fail_on_skipped()
671 .with_default_cli()
672 .filter_run(FEATURE_PATH, move |feature, _, scenario| {
673 feature.name == FEATURE_NAME && scenario.name == name
674 })
675 .await;
676 assert_eq!(writer.failed_steps(), 0, "scenario failed: {name}");
677 assert_eq!(writer.skipped_steps(), 0, "scenario skipped steps: {name}");
678 assert_eq!(
679 writer.passed_steps(),
680 expected_steps,
681 "scenario did not run: {name}"
682 );
683 }
684
685 fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App {
686 let options = TuiOptions {
687 skills_dir: tmpdir.path().join("skills"),
688 memory_path: tmpdir.path().join("memory.md"),
689 notes_path: tmpdir.path().join("notes.txt"),
690 mcp_config_path: tmpdir.path().join("mcp.json"),
691 ..crate::test_support::test_tui_options(tmpdir.path())
692 };
693 App::new(options, &Config::default())
694 }
695
696 fn execute_isolated(world: &mut SessionCommandWorld, command: &str) -> CommandResult {
697 let home = world
698 .home_path
699 .as_ref()
700 .expect("test home should exist")
701 .clone();
702 std::fs::create_dir_all(&home).expect("create isolated test home");
703
704 let _lock = lock_test_env();
705 let _home = EnvVarGuard::set("HOME", &home);
706 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", home.join(".codewhale"));
707
708 let app = world.app.as_deref_mut().expect("app should exist");
709 commands::user_registry::reload(Some(&app.workspace));
710 commands::execute(command, app)
711 }
712
713 fn remember_result(world: &mut SessionCommandWorld, result: &CommandResult) {
714 world.last_result_is_error = Some(result.is_error);
715 world.last_message = result.message.clone();
716 world.last_action = result.action.clone();
717 }
718
719 fn persist_active_session(world: &SessionCommandWorld) {
720 let app = world.app.as_deref().expect("app should exist");
721 let session_id = app
722 .current_session_id
723 .as_ref()
724 .expect("active session id should exist")
725 .clone();
726 let session = create_saved_session_with_id_and_mode(
727 session_id,
728 &app.api_messages,
729 &app.model,
730 &app.workspace,
731 u64::from(app.session.total_tokens),
732 app.system_prompt.as_ref(),
733 Some(app.mode.label()),
734 );
735 let home = world
736 .home_path
737 .as_ref()
738 .expect("test home should exist")
739 .clone();
740 std::fs::create_dir_all(&home).expect("create isolated test home");
741
742 let _lock = lock_test_env();
743 let _home = EnvVarGuard::set("HOME", &home);
744 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", home.join(".codewhale"));
745 let manager = SessionManager::default_location().expect("open isolated session manager");
746
747 manager
748 .save_session(&session)
749 .expect("persist active session");
750 }
751
752 fn persist_session_with_age(world: &SessionCommandWorld, session_id: &str, title: &str, days: i64) {
753 let app = world.app.as_deref().expect("app should exist");
754 let mut session = create_saved_session_with_id_and_mode(
755 session_id.to_string(),
756 &app.api_messages,
757 &app.model,
758 &app.workspace,
759 u64::from(app.session.total_tokens),
760 app.system_prompt.as_ref(),
761 Some(app.mode.label()),
762 );
763 let timestamp = Utc::now() - ChronoDuration::days(days);
764 session.metadata.title = title.to_string();
765 session.metadata.created_at = timestamp;
766 session.metadata.updated_at = timestamp;
767
768 let home = world
769 .home_path
770 .as_ref()
771 .expect("test home should exist")
772 .clone();
773 std::fs::create_dir_all(&home).expect("create isolated test home");
774
775 let _lock = lock_test_env();
776 let _home = EnvVarGuard::set("HOME", &home);
777 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", home.join(".codewhale"));
778 let manager = SessionManager::default_location().expect("open isolated session manager");
779
780 manager.save_session(&session).expect("persist session");
781 }
782
783 fn load_saved_session(world: &SessionCommandWorld, session_id: &str) -> SavedSession {
784 try_load_saved_session(world, session_id)
785 .unwrap_or_else(|_| panic!("load saved session failed"))
786 }
787
788 fn try_load_saved_session(
789 world: &SessionCommandWorld,
790 session_id: &str,
791 ) -> std::io::Result<SavedSession> {
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.load_session(session_id)
805 }
806
807 fn read_saved_session_file(world: &SessionCommandWorld) -> SavedSession {
808 let save_path = world.save_path.as_ref().expect("save path should exist");
809 let content = std::fs::read_to_string(save_path)
810 .unwrap_or_else(|err| panic!("read saved session file {save_path:?}: {err}"));
811
812 serde_json::from_str(&content)
813 .unwrap_or_else(|err| panic!("parse saved session file {save_path:?}: {err}"))
814 }
815
816 fn assert_app_contains_message(app: &App, expected: &str) {
817 let message = app
818 .api_messages
819 .first()
820 .expect("active session should contain one message");
821 let content = message
822 .content
823 .iter()
824 .find_map(text_content)
825 .expect("active message should contain text");
826
827 assert_eq!(message.role, "user");
828 assert_eq!(content, expected);
829 }
830
831 fn assert_saved_session_contains_message(session: &SavedSession, expected: &str) {
832 let message = session
833 .messages
834 .first()
835 .expect("saved session should contain one message");
836 let content = message
837 .content
838 .iter()
839 .find_map(text_content)
840 .expect("saved message should contain text");
841
842 assert_eq!(message.role, "user");
843 assert_eq!(content, expected);
844 }
845
846 fn text_content(block: &ContentBlock) -> Option<&str> {
847 match block {
848 ContentBlock::Text { text, .. } => Some(text.as_str()),
849 _ => None,
850 }
851 }
852
852 lines RUST