返回 CodeWhale
resume.rs
根目录 / crates / tui / src / commands / groups / session / resume.rs
1 //! `/resume` command — portable handler over the session-control facet.
2 //!
3 //! The handler owns route selection and the exact per-route messages/actions;
4 //! filesystem, manager, parser, and picker machinery stay behind the facet's
5 //! `resolve_resume_source` / `import_session_file` / `open_resume_picker`
6 //! delegates (transition blocking is checked before any picker or I/O).
7
8 use super::CommandResult;
9 use codewhale_command_contract::facets::{CommandSessionControlContext, ResumeSource};
10 use codewhale_command_contract::handler::{CommandContexts, CommandHandler};
11 use codewhale_command_contract::metadata::{
12 CommandInfo as ContractInfo, RegisterCommand as ContractRegisterCommand,
13 };
14
15 pub(in crate::commands) struct ResumeCmd;
16
17 pub(in crate::commands) const CONTRACT_INFO: ContractInfo = ContractInfo {
18 name: "resume",
19 aliases: &["r"],
20 usage: "/resume [session_id|path/to/export.json]",
21 description_key: "cmd_resume_description",
22 };
23
24 impl ContractRegisterCommand<CommandResult> for ResumeCmd {
25 fn info() -> &'static ContractInfo {
26 &CONTRACT_INFO
27 }
28 fn handler() -> CommandHandler<CommandResult> {
29 CommandHandler::Contextual {
30 capabilities: codewhale_command_contract::handler::CommandCapabilities::SESSION_CONTROL,
31 handler: resume_contextual,
32 }
33 }
34 }
35
36 pub(in crate::commands) fn resume_contextual(
37 contexts: CommandContexts<'_>,
38 arg: Option<&str>,
39 ) -> CommandResult {
40 let mut parts = contexts.into_parts();
41 let Some(control) = parts.control.as_deref_mut() else {
42 return CommandResult::error("Command capability unavailable: session_control".to_string());
43 };
44 resume_portable(control, arg)
45 }
46
47 pub(in crate::commands) fn resume_portable(
48 control: &mut dyn CommandSessionControlContext,
49 arg: Option<&str>,
50 ) -> CommandResult {
51 if control.transition_blocked() {
52 return CommandResult::error(
53 "Cannot resume while runtime work is active. Wait for the turn to finish, or cancel it first.",
54 );
55 }
56 let Some(raw) = arg.map(str::trim).filter(|s| !s.is_empty()) else {
57 control.open_resume_picker();
58 return CommandResult::ok();
59 };
60 match control.resolve_resume_source(raw) {
61 Ok(ResumeSource::File(path)) => match control.import_session_file(path) {
62 Ok(receipt) => CommandResult::with_message_and_action(
63 format!(
64 "Imported foreign session as {} ({} entries, leaf {})",
65 receipt.truncated_id, receipt.entry_count, receipt.leaf_display
66 ),
67 codewhale_command_contract::outcome::SessionAction::SyncSession(receipt.sync),
68 ),
69 Err(error) => CommandResult::error(error),
70 },
71 Ok(ResumeSource::Imported(receipt)) => CommandResult::with_message_and_action(
72 format!(
73 "Imported foreign session as {} ({} entries, leaf {})",
74 receipt.truncated_id, receipt.entry_count, receipt.leaf_display
75 ),
76 codewhale_command_contract::outcome::SessionAction::SyncSession(receipt.sync),
77 ),
78 Ok(ResumeSource::Session {
79 load_path,
80 truncated_id,
81 title,
82 }) => match load_path {
83 Some(path) => CommandResult::action(
84 codewhale_command_contract::outcome::SessionAction::LoadSession(path),
85 ),
86 None => CommandResult::message(format!("Resuming session {truncated_id} ({title})")),
87 },
88 Ok(ResumeSource::NotFound { raw, error }) => CommandResult::error(format!(
89 "Cannot resume '{raw}': {error}\nUse `/resume` without args to pick, or pass a session id, or a path to an exported session JSON."
90 )),
91 Err(error) => CommandResult::error(error),
92 }
93 }
94
95 #[cfg(test)]
96 mod tests {
97 use super::super::control_test_support::message;
98 use super::*;
99 use codewhale_command_contract::facets::ResumeImportReceipt;
100 use std::path::PathBuf;
101
102 fn control_fake() -> super::super::control_test_support::FakeControl {
103 super::super::control_test_support::FakeControl::default()
104 }
105
106 #[test]
107 fn resume_transition_blocking_wins_before_any_route() {
108 let mut fake = control_fake();
109 fake.blocked = true;
110 let result = resume_portable(&mut fake, Some("anything"));
111 assert!(result.is_error);
112 assert_eq!(
113 message(&result),
114 "Cannot resume while runtime work is active. Wait for the turn to finish, or cancel it first."
115 );
116 assert_eq!(
117 fake.calls.borrow().as_slice(),
118 ["transition_blocked"],
119 "the gate executes exactly once before route work"
120 );
121 }
122
123 #[test]
124 fn resume_bare_opens_the_picker() {
125 let mut fake = control_fake();
126 let result = resume_portable(&mut fake, None);
127 assert!(!result.is_error);
128 assert!(result.action.is_none());
129 assert!(result.message.is_none());
130 assert_eq!(
131 fake.calls.borrow().as_slice(),
132 ["transition_blocked", "open_resume_picker"]
133 );
134 }
135
136 #[test]
137 fn resume_file_and_import_routes_compose_exact_receipts() {
138 let mut fake = control_fake();
139 fake.resume = Some(Ok(ResumeSource::File(PathBuf::from("/tmp/import.json"))));
140 fake.import = Some(Ok(ResumeImportReceipt {
141 truncated_id: "imp-9".to_string(),
142 entry_count: 12,
143 leaf_display: "leaf-3".to_string(),
144 sync: super::super::lifecycle_test_support::sync_payload("imp-9"),
145 }));
146 let result = resume_portable(&mut fake, Some("/tmp/import.json"));
147 assert!(!result.is_error);
148 assert_eq!(
149 message(&result),
150 "Imported foreign session as imp-9 (12 entries, leaf leaf-3)"
151 );
152 // The engine must adopt the imported conversation; a message-only
153 // result would leave it on the previous session.
154 assert!(
155 matches!(
156 result.action,
157 Some(codewhale_command_contract::outcome::SessionAction::SyncSession(codewhale_command_contract::facets::SessionSyncPayload { ref session_id, .. }))
158 if session_id.as_deref() == Some("imp-9")
159 ),
160 "{result:?}"
161 );
162
163 let mut fake = control_fake();
164 fake.resume = Some(Ok(ResumeSource::File(PathBuf::from("/tmp/x.json"))));
165 fake.import = Some(Err(
166 "File x.json is not a recognized session export".to_string()
167 ));
168 let result = resume_portable(&mut fake, Some("/tmp/x.json"));
169 assert!(result.is_error);
170 assert_eq!(
171 message(&result),
172 "File x.json is not a recognized session export"
173 );
174
175 let mut fake = control_fake();
176 fake.resume = Some(Ok(ResumeSource::Imported(ResumeImportReceipt {
177 truncated_id: "c-1".to_string(),
178 entry_count: 0,
179 leaf_display: "(none)".to_string(),
180 sync: super::super::lifecycle_test_support::sync_payload("c-1"),
181 })));
182 let result = resume_portable(&mut fake, Some("inline-json"));
183 assert_eq!(
184 message(&result),
185 "Imported foreign session as c-1 (0 entries, leaf (none))"
186 );
187 assert!(
188 matches!(
189 result.action,
190 Some(codewhale_command_contract::outcome::SessionAction::SyncSession(codewhale_command_contract::facets::SessionSyncPayload { ref session_id, .. }))
191 if session_id.as_deref() == Some("c-1")
192 ),
193 "{result:?}"
194 );
195 }
196
197 #[test]
198 fn resume_session_and_not_found_routes_are_exact() {
199 let mut fake = control_fake();
200 fake.resume = Some(Ok(ResumeSource::Session {
201 load_path: Some(PathBuf::from("/tmp/sessions/abc123.json")),
202 truncated_id: "abc123".to_string(),
203 title: "Control Session".to_string(),
204 }));
205 let result = resume_portable(&mut fake, Some("abc123"));
206 assert!(!result.is_error);
207 assert!(matches!(
208 result.action,
209 Some(codewhale_command_contract::outcome::SessionAction::LoadSession(path)) if path == *"/tmp/sessions/abc123.json"
210 ));
211
212 let mut fake = control_fake();
213 fake.resume = Some(Ok(ResumeSource::Session {
214 load_path: None,
215 truncated_id: "abc123".to_string(),
216 title: "Control Session".to_string(),
217 }));
218 let result = resume_portable(&mut fake, Some("abc123"));
219 assert_eq!(
220 message(&result),
221 "Resuming session abc123 (Control Session)"
222 );
223
224 let mut fake = control_fake();
225 fake.resume = Some(Ok(ResumeSource::NotFound {
226 raw: "nope".to_string(),
227 error: "no such session".to_string(),
228 }));
229 let result = resume_portable(&mut fake, Some("nope"));
230 assert!(result.is_error);
231 assert_eq!(
232 message(&result),
233 "Cannot resume 'nope': no such session\nUse `/resume` without args to pick, or pass a session id, or a path to an exported session JSON."
234 );
235 }
236
237 #[test]
238 fn resume_host_lookup_errors_pass_through() {
239 let mut fake = control_fake();
240 fake.resume = Some(Err("could not open sessions directory: boom".to_string()));
241 let result = resume_portable(&mut fake, Some("abc"));
242 assert!(result.is_error);
243 assert_eq!(message(&result), "could not open sessions directory: boom");
244 }
245
246 #[test]
247 fn resume_missing_control_authority_fails_safely() {
248 let contexts = codewhale_command_contract::handler::CommandContexts::empty();
249 let result = resume_contextual(contexts, None);
250 assert!(result.is_error);
251 assert_eq!(
252 message(&result),
253 "Command capability unavailable: session_control"
254 );
255 }
256 }
257
257 lines RUST