| 1 | //! `/relay` command — portable handler over the session-control facet. |
| 2 | |
| 3 | use std::fmt::Write as _; |
| 4 | |
| 5 | use super::CommandResult; |
| 6 | use codewhale_command_contract::facets::{ |
| 7 | CommandSessionControlContext, PlanProjection, PlanStepStatus, |
| 8 | }; |
| 9 | use codewhale_command_contract::handler::{CommandContexts, CommandHandler}; |
| 10 | use codewhale_command_contract::metadata::{ |
| 11 | CommandInfo as ContractInfo, RegisterCommand as ContractRegisterCommand, |
| 12 | }; |
| 13 | |
| 14 | pub(in crate::commands) struct RelayCmd; |
| 15 | |
| 16 | // --------------------------------------------------------------------------- |
| 17 | // FEAT-024 Phase 4 (D4/D6/D7): portable contextual registration and handler. |
| 18 | // The handler owns complete relay-instruction composition (sections, labels, |
| 19 | // list formatting, focus normalization, byte-identical text); the facet |
| 20 | // supplies the semantic projection. Missing control authority fails safely |
| 21 | // with the exact capability error (never a panic). |
| 22 | // --------------------------------------------------------------------------- |
| 23 | |
| 24 | pub(in crate::commands) const CONTRACT_INFO: ContractInfo = ContractInfo { |
| 25 | name: "relay", |
| 26 | aliases: &["batonpass", "接力"], |
| 27 | usage: "/relay [focus]", |
| 28 | description_key: "cmd_relay_description", |
| 29 | }; |
| 30 | |
| 31 | impl ContractRegisterCommand<CommandResult> for RelayCmd { |
| 32 | fn info() -> &'static ContractInfo { |
| 33 | &CONTRACT_INFO |
| 34 | } |
| 35 | fn handler() -> CommandHandler<CommandResult> { |
| 36 | CommandHandler::Contextual { |
| 37 | capabilities: codewhale_command_contract::handler::CommandCapabilities::SESSION_CONTROL, |
| 38 | handler: relay_contextual, |
| 39 | } |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | pub(in crate::commands) fn relay_contextual( |
| 44 | contexts: CommandContexts<'_>, |
| 45 | arg: Option<&str>, |
| 46 | ) -> CommandResult { |
| 47 | let mut parts = contexts.into_parts(); |
| 48 | let Some(control) = parts.control.as_deref_mut() else { |
| 49 | return CommandResult::error("Command capability unavailable: session_control".to_string()); |
| 50 | }; |
| 51 | relay_portable(control, arg) |
| 52 | } |
| 53 | |
| 54 | pub(in crate::commands) fn relay_portable( |
| 55 | control: &mut dyn CommandSessionControlContext, |
| 56 | arg: Option<&str>, |
| 57 | ) -> CommandResult { |
| 58 | let focus = arg.map(str::trim).filter(|value| !value.is_empty()); |
| 59 | let message = build_relay_instruction(control, focus); |
| 60 | CommandResult::with_message_and_action( |
| 61 | "Preparing session relay at .deepseek/handoff.md...", |
| 62 | crate::tui::app::AppAction::SendMessage(message), |
| 63 | ) |
| 64 | } |
| 65 | |
| 66 | /// Compose the byte-identical relay instruction from the portable snapshot. |
| 67 | fn build_relay_instruction( |
| 68 | control: &dyn CommandSessionControlContext, |
| 69 | focus: Option<&str>, |
| 70 | ) -> String { |
| 71 | let projection = control.relay_projection(); |
| 72 | let mut out = String::new(); |
| 73 | let _ = writeln!( |
| 74 | out, |
| 75 | "Create a compact session relay (接力) for a future Codewhale thread." |
| 76 | ); |
| 77 | let _ = writeln!(out); |
| 78 | let _ = writeln!(out, "Write or update `.deepseek/handoff.md`."); |
| 79 | let _ = writeln!( |
| 80 | out, |
| 81 | "Keep the existing file path for compatibility, but title the artifact `# Session relay`." |
| 82 | ); |
| 83 | let _ = writeln!(out); |
| 84 | let _ = writeln!(out, "Use this relay structure:"); |
| 85 | let _ = writeln!(out); |
| 86 | let _ = writeln!(out, "{}", projection.compact_template.trim()); |
| 87 | let _ = writeln!(out); |
| 88 | let _ = writeln!(out, "Current session snapshot:"); |
| 89 | let _ = writeln!(out, "- Workspace: {}", projection.workspace); |
| 90 | let _ = writeln!(out, "- Mode: {}", projection.mode); |
| 91 | let _ = writeln!(out, "- Model: {}", projection.model); |
| 92 | if let Some(focus) = focus { |
| 93 | let _ = writeln!(out, "- Requested relay focus: {focus}"); |
| 94 | } |
| 95 | if let Some(objective) = projection.goal_objective.as_deref() { |
| 96 | let _ = writeln!(out, "- Goal objective: {objective}"); |
| 97 | } |
| 98 | if let Some(budget) = projection.goal_token_budget { |
| 99 | let _ = writeln!(out, "- Goal token budget: {budget}"); |
| 100 | } |
| 101 | match projection.todos { |
| 102 | codewhale_command_contract::facets::TodoProjection::Body(body) => { |
| 103 | let _ = writeln!(out, "\nCurrent To-do:"); |
| 104 | let _ = writeln!(out, "{body}"); |
| 105 | } |
| 106 | codewhale_command_contract::facets::TodoProjection::Absent => {} |
| 107 | codewhale_command_contract::facets::TodoProjection::Unavailable => { |
| 108 | let _ = writeln!(out, "\nTo-do: unavailable because the list is busy."); |
| 109 | } |
| 110 | } |
| 111 | match projection.plan { |
| 112 | PlanProjection::Sections(sections) => { |
| 113 | let _ = writeln!( |
| 114 | out, |
| 115 | "\nConversational strategy notes from update_plan (reasoning context, not a Work surface):" |
| 116 | ); |
| 117 | write_plan_field(&mut out, "Title", sections.title.as_deref()); |
| 118 | write_plan_field(&mut out, "Objective", sections.objective.as_deref()); |
| 119 | write_plan_field(&mut out, "Context", sections.context_summary.as_deref()); |
| 120 | write_plan_field(&mut out, "Explanation", sections.explanation.as_deref()); |
| 121 | write_plan_list(&mut out, "Source", §ions.sources_used); |
| 122 | write_plan_list(&mut out, "Critical file", §ions.critical_files); |
| 123 | write_plan_list(&mut out, "Constraint", §ions.constraints); |
| 124 | write_plan_field( |
| 125 | &mut out, |
| 126 | "Recommended approach", |
| 127 | sections.recommended_approach.as_deref(), |
| 128 | ); |
| 129 | write_plan_field( |
| 130 | &mut out, |
| 131 | "Verification plan", |
| 132 | sections.verification_plan.as_deref(), |
| 133 | ); |
| 134 | write_plan_field( |
| 135 | &mut out, |
| 136 | "Risks and unknowns", |
| 137 | sections.risks_and_unknowns.as_deref(), |
| 138 | ); |
| 139 | write_plan_field( |
| 140 | &mut out, |
| 141 | "Handoff packet", |
| 142 | sections.handoff_packet.as_deref(), |
| 143 | ); |
| 144 | for item in sections.items { |
| 145 | let _ = writeln!( |
| 146 | out, |
| 147 | "- [{}] {}", |
| 148 | plan_step_status_label(item.status), |
| 149 | item.text |
| 150 | ); |
| 151 | } |
| 152 | } |
| 153 | PlanProjection::Absent => {} |
| 154 | PlanProjection::Busy => { |
| 155 | let _ = writeln!( |
| 156 | out, |
| 157 | "\nStrategy metadata: unavailable because plan state is busy." |
| 158 | ); |
| 159 | } |
| 160 | } |
| 161 | let _ = writeln!( |
| 162 | out, |
| 163 | "\nBefore writing, inspect the current transcript context and any live tool evidence you need. Do not invent test results, file changes, blockers, or decisions." |
| 164 | ); |
| 165 | let _ = writeln!( |
| 166 | out, |
| 167 | "\nKeep it under about 900 words unless the session genuinely needs more. After writing, report the path and the single next action." |
| 168 | ); |
| 169 | out |
| 170 | } |
| 171 | |
| 172 | fn plan_step_status_label(status: PlanStepStatus) -> &'static str { |
| 173 | match status { |
| 174 | PlanStepStatus::Pending => "pending", |
| 175 | PlanStepStatus::InProgress => "in_progress", |
| 176 | PlanStepStatus::Completed => "completed", |
| 177 | } |
| 178 | } |
| 179 | |
| 180 | fn write_plan_field(out: &mut String, label: &str, value: Option<&str>) { |
| 181 | if let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) { |
| 182 | let _ = writeln!(out, "- {label}: {value}"); |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | fn write_plan_list(out: &mut String, label: &str, values: &[String]) { |
| 187 | for value in values { |
| 188 | let value = value.trim(); |
| 189 | if !value.is_empty() { |
| 190 | let _ = writeln!(out, "- {label}: {value}"); |
| 191 | } |
| 192 | } |
| 193 | } |
| 194 | |
| 195 | #[cfg(test)] |
| 196 | mod tests { |
| 197 | use super::super::control_test_support::message; |
| 198 | use super::*; |
| 199 | use codewhale_command_contract::facets::{ |
| 200 | PlanSections, PlanStep, RelayProjection, TodoProjection, |
| 201 | }; |
| 202 | |
| 203 | #[test] |
| 204 | fn relay_composes_exact_instruction_and_action() { |
| 205 | let mut fake = super::super::control_test_support::FakeControl { |
| 206 | relay: Some(super::super::control_test_support::relay_projection_fixture()), |
| 207 | ..super::super::control_test_support::FakeControl::default() |
| 208 | }; |
| 209 | let result = relay_portable(&mut fake, Some("focus on the handoff")); |
| 210 | assert!(!result.is_error); |
| 211 | let message = match result.action { |
| 212 | Some(crate::tui::app::AppAction::SendMessage(message)) => message, |
| 213 | other => panic!("expected SendMessage action, got {other:?}"), |
| 214 | }; |
| 215 | assert!( |
| 216 | message |
| 217 | .contains("Create a compact session relay (接力) for a future Codewhale thread.") |
| 218 | ); |
| 219 | assert!(message.contains("- Workspace: /work")); |
| 220 | assert!(message.contains("- Mode: operate")); |
| 221 | assert!(message.contains("- Model: model-x")); |
| 222 | assert!(message.contains("- Requested relay focus: focus on the handoff")); |
| 223 | assert!(message.contains("- Goal objective: objective-y")); |
| 224 | assert!(message.contains("- Goal token budget: 900")); |
| 225 | assert!(message.contains("Keep the existing file path for compatibility, but title the artifact `# Session relay`.")); |
| 226 | assert!(message.contains("Before writing, inspect the current transcript context")); |
| 227 | assert!(message.contains("Keep it under about 900 words")); |
| 228 | assert!(!message.contains("Current To-do:")); |
| 229 | assert!(!message.contains("strategy notes")); |
| 230 | assert!( |
| 231 | result |
| 232 | .message |
| 233 | .as_deref() |
| 234 | .is_some_and(|m| m == "Preparing session relay at .deepseek/handoff.md...") |
| 235 | ); |
| 236 | assert_eq!(fake.calls.borrow().as_slice(), ["relay_projection"]); |
| 237 | } |
| 238 | |
| 239 | #[test] |
| 240 | fn relay_render_optional_sections_and_busy_states_exactly() { |
| 241 | let mut fake = super::super::control_test_support::FakeControl { |
| 242 | relay: Some(RelayProjection { |
| 243 | compact_template: "# Session relay".to_string(), |
| 244 | workspace: "/work".to_string(), |
| 245 | mode: "operate".to_string(), |
| 246 | model: "model-x".to_string(), |
| 247 | goal_objective: None, |
| 248 | goal_token_budget: None, |
| 249 | todos: TodoProjection::Unavailable, |
| 250 | plan: codewhale_command_contract::facets::PlanProjection::Busy, |
| 251 | }), |
| 252 | ..super::super::control_test_support::FakeControl::default() |
| 253 | }; |
| 254 | let result = relay_portable(&mut fake, None); |
| 255 | let message = match result.action { |
| 256 | Some(crate::tui::app::AppAction::SendMessage(message)) => message, |
| 257 | other => panic!("expected SendMessage, got {other:?}"), |
| 258 | }; |
| 259 | assert!(message.contains("\nTo-do: unavailable because the list is busy.")); |
| 260 | assert!(message.contains("\nStrategy metadata: unavailable because plan state is busy.")); |
| 261 | assert!(!message.contains("Requested relay focus")); |
| 262 | assert!(!message.contains("Goal objective")); |
| 263 | assert!(!message.contains("Goal token budget")); |
| 264 | } |
| 265 | |
| 266 | #[test] |
| 267 | fn relay_renders_plan_sections_with_exact_labels() { |
| 268 | let mut fake = super::super::control_test_support::FakeControl { |
| 269 | relay: Some(RelayProjection { |
| 270 | compact_template: "template".to_string(), |
| 271 | workspace: "/w".to_string(), |
| 272 | mode: "m".to_string(), |
| 273 | model: "mo".to_string(), |
| 274 | goal_objective: None, |
| 275 | goal_token_budget: None, |
| 276 | todos: TodoProjection::Absent, |
| 277 | plan: codewhale_command_contract::facets::PlanProjection::Sections(PlanSections { |
| 278 | title: Some("Relay Plan".to_string()), |
| 279 | explanation: Some(" because ".to_string()), |
| 280 | sources_used: vec!["repo-a".to_string(), " ".to_string()], |
| 281 | items: vec![PlanStep { |
| 282 | status: PlanStepStatus::InProgress, |
| 283 | text: "port relay".to_string(), |
| 284 | }], |
| 285 | ..PlanSections::default() |
| 286 | }), |
| 287 | }), |
| 288 | ..super::super::control_test_support::FakeControl::default() |
| 289 | }; |
| 290 | let result = relay_portable(&mut fake, None); |
| 291 | let message = match result.action { |
| 292 | Some(crate::tui::app::AppAction::SendMessage(message)) => message, |
| 293 | other => panic!("expected SendMessage, got {other:?}"), |
| 294 | }; |
| 295 | assert!(message.contains("- Title: Relay Plan")); |
| 296 | assert!(message.contains("- Explanation: because")); |
| 297 | assert!(message.contains("- Source: repo-a")); |
| 298 | assert!(message.contains("- [in_progress] port relay")); |
| 299 | } |
| 300 | |
| 301 | #[test] |
| 302 | fn relay_owns_every_portable_plan_status_label() { |
| 303 | assert_eq!(plan_step_status_label(PlanStepStatus::Pending), "pending"); |
| 304 | assert_eq!( |
| 305 | plan_step_status_label(PlanStepStatus::InProgress), |
| 306 | "in_progress" |
| 307 | ); |
| 308 | assert_eq!( |
| 309 | plan_step_status_label(PlanStepStatus::Completed), |
| 310 | "completed" |
| 311 | ); |
| 312 | } |
| 313 | |
| 314 | #[test] |
| 315 | fn relay_missing_control_authority_fails_safely() { |
| 316 | let contexts = codewhale_command_contract::handler::CommandContexts::empty(); |
| 317 | let result = relay_contextual(contexts, None); |
| 318 | assert!(result.is_error); |
| 319 | assert_eq!( |
| 320 | message(&result), |
| 321 | "Command capability unavailable: session_control" |
| 322 | ); |
| 323 | } |
| 324 | } |
| 325 |