返回 CodeWhale
relay.rs
根目录 / crates / tui / src / commands / groups / session / relay.rs
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", &sections.sources_used);
122 write_plan_list(&mut out, "Critical file", &sections.critical_files);
123 write_plan_list(&mut out, "Constraint", &sections.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
325 lines RUST