1use std::collections::HashMap;
8use std::sync::Arc;
9use std::time::{Duration, Instant};
10
11use tokio::sync::Mutex;
12
13use crate::backends::{ExternalId, Status};
14use crate::channels::capture::CaptureService;
15use crate::channels::routing::ChannelRouter;
16use crate::channels::transport::conversation_key;
17use crate::channels::transport::{MessageRoute, Transport};
18use crate::channels::{ChannelRegistry, IncomingMessage, OutgoingMessage};
19use crate::engine::commands::{CommandStoreOps, execute_command, parse_command};
20use crate::engine::tasks::{CreateTaskRequest, Task, TaskType};
21use crate::github::http::GhHttp;
22use crate::store::TaskStatus;
23use crate::tmux::TmuxManager;
24
25use super::EngineRef;
26use crate::channels::stream::fanout_output;
27use crate::channels::tmux;
28use crate::channels::transport;
29use crate::control;
30
31const PICK_TIMEOUT: Duration = Duration::from_secs(60);
35
36#[derive(Debug)]
41pub struct PendingPick {
42 pub original_body: String,
44 pub expires_at: Instant,
46}
47
48impl PendingPick {
49 pub fn new(original_body: String) -> Self {
50 Self {
51 original_body,
52 expires_at: Instant::now() + PICK_TIMEOUT,
53 }
54 }
55
56 pub fn is_expired(&self) -> bool {
57 Instant::now() >= self.expires_at
58 }
59}
60
61pub type PendingPicks = Arc<Mutex<HashMap<String, PendingPick>>>;
66
67pub fn parse_pick_callback(body: &str) -> Option<(String, String)> {
71 let rest = body.strip_prefix("pick:")?;
72 let colon = rest.find(':')?;
73 let orig_id = &rest[..colon];
74 let repo = &rest[colon + 1..];
75 if orig_id.is_empty() || repo.is_empty() {
76 return None;
77 }
78 Some((orig_id.to_string(), repo.to_string()))
79}
80
81pub fn buttons_for_picker(repos: &[&str], original_msg_id: &str) -> Vec<(String, String)> {
89 repos
90 .iter()
91 .map(|repo| {
92 let label = repo.rsplit('/').next().unwrap_or(repo).to_string();
93 let callback_data = format!("pick:{original_msg_id}:{repo}");
94 (label, callback_data)
95 })
96 .collect()
97}
98
99fn control_session_id(channel: &str, thread_id: &str, topic_id: Option<&str>) -> String {
100 format!("control:{}", conversation_key(channel, thread_id, topic_id))
101}
102
103fn normalize_channel_route(
104 msg: &IncomingMessage,
105 route: MessageRoute,
106 is_control: bool,
107) -> MessageRoute {
108 if is_control {
109 return MessageRoute::ControlSession;
110 }
111
112 match route {
113 MessageRoute::NewTask
117 if msg.channel == "telegram" && parse_pick_callback(msg.body.trim()).is_none() =>
118 {
119 MessageRoute::ControlSession
120 }
121 other => other,
122 }
123}
124
125pub(super) async fn send_channel_reply(
131 channels: &Arc<ChannelRegistry>,
132 channel_name: &str,
133 thread_id: &str,
134 body: String,
135 topic_id: Option<&str>,
136) {
137 for ch in channels.iter() {
138 if ch.name() == channel_name {
139 let msg = OutgoingMessage {
140 thread_id: thread_id.to_string(),
141 body,
142 reply_to: None,
143 metadata: serde_json::json!({}),
144 topic_id: topic_id.map(String::from),
145 };
146 if let Err(e) = ch.send(&msg).await {
147 tracing::warn!(
148 channel = channel_name,
149 thread_id,
150 ?e,
151 "failed to send channel reply"
152 );
153 }
154 return;
155 }
156 }
157 tracing::debug!(
158 channel = channel_name,
159 "channel not found in registry for reply"
160 );
161}
162
163pub(super) async fn forward_to_tmux(
165 transport: &Arc<Transport>,
166 repo: &str,
167 task_id: &str,
168 text: &str,
169) {
170 match transport.get_binding(repo, task_id).await {
171 Some(binding) => match tmux::send_keys(&binding.tmux_session, text).await {
172 Err(e) => {
173 tracing::warn!(
174 task_id,
175 session = %binding.tmux_session,
176 ?e,
177 "failed to forward message to tmux session"
178 );
179 }
180 _ => {
181 tracing::debug!(
182 task_id,
183 session = %binding.tmux_session,
184 "forwarded message to tmux"
185 );
186 }
187 },
188 _ => {
189 tracing::warn!(task_id, "no tmux binding found, cannot forward message");
190 }
191 }
192}
193
194fn engine_ref_idx_for_session(
200 repos: &[&str],
201 tmux: &TmuxManager,
202 task_id: &str,
203 tmux_session: &str,
204) -> usize {
205 repos
206 .iter()
207 .position(|repo| tmux.session_name(repo, task_id) == tmux_session)
208 .unwrap_or(0)
209}
210
211async fn ack_channel_interaction(
217 channels: &Arc<ChannelRegistry>,
218 channel_name: &str,
219 metadata: &serde_json::Value,
220) {
221 if channel_name != "telegram" {
222 return;
223 }
224 let Some(cb_id) = metadata["callback_query_id"].as_str() else {
225 return;
226 };
227 for ch in channels.iter() {
228 if ch.name() == channel_name {
229 if let Err(e) = ch.ack_interaction(cb_id).await {
230 tracing::warn!(channel = channel_name, ?e, "failed to ack callback query");
231 }
232 return;
233 }
234 }
235}
236
237#[expect(
243 clippy::too_many_arguments,
244 reason = "shared by two call paths that each hold these pieces separately"
245)]
246async fn create_and_announce_task(
247 body: &str,
248 forced_repo: Option<&str>,
249 channel: &str,
250 thread_id: &str,
251 topic_id: Option<&str>,
252 engine_refs: &[EngineRef],
253 transport: &Arc<Transport>,
254 tmux: &Arc<TmuxManager>,
255 capture: &Arc<CaptureService>,
256 channels: &Arc<ChannelRegistry>,
257) {
258 let target = if let Some(repo) = forced_repo {
259 engine_refs.iter().find(|(r, _, _, _)| r == repo)
260 } else {
261 engine_refs.first()
262 };
263
264 let Some((repo, _, task_manager, _)) = target else {
265 tracing::warn!("no project configured, cannot create task from channel message");
266 send_channel_reply(
267 channels,
268 channel,
269 thread_id,
270 "No project configured. Cannot create task.".to_string(),
271 topic_id,
272 )
273 .await;
274 return;
275 };
276
277 let title = if body.chars().count() > 80 {
278 let truncated: String = body.chars().take(80).collect();
279 format!("{truncated}…")
280 } else {
281 body.to_string()
282 };
283 let req = CreateTaskRequest {
284 title,
285 body: body.to_string(),
286 task_type: TaskType::Internal,
287 labels: vec!["channel-created".to_string()],
288 source: channel.to_string(),
289 source_id: thread_id.to_string(),
290 };
291 match task_manager.create_task(req).await {
292 Ok(task) => {
293 let task_id = match &task {
294 Task::Internal(t) => format!("internal:{}", t.id),
295 Task::External(t) => t.id.0.clone(),
296 };
297 let session_name = tmux.session_name(repo, &task_id);
298 transport
299 .bind(repo, &task_id, &session_name, channel, thread_id, topic_id)
300 .await;
301 capture
302 .register_session(repo, &task_id, &session_name)
303 .await;
304 let transport_clone = Arc::clone(transport);
305 let channels_clone = Arc::clone(channels);
306 let task_id_clone = task_id.clone();
307 let repo_clone = repo.clone();
308 tokio::spawn(async move {
309 fanout_output(repo_clone, task_id_clone, transport_clone, channels_clone).await;
310 });
311 let project_label = format!(" in [{repo}]");
313 let reply =
314 format!("Task created: `{task_id}`{project_label} — I'll start working on it now.");
315 send_channel_reply(channels, channel, thread_id, reply, topic_id).await;
316 }
317 Err(e) => {
318 tracing::warn!(repo, err = %e, "failed to create task from channel message");
319 send_channel_reply(
320 channels,
321 channel,
322 thread_id,
323 format!("Failed to create task: {e}"),
324 topic_id,
325 )
326 .await;
327 }
328 }
329}
330
331#[expect(
343 clippy::too_many_arguments,
344 reason = "the channel loop passes each engine handle separately"
345)]
346pub(super) async fn handle_channel_message(
347 msg: IncomingMessage,
348 transport: &Arc<Transport>,
349 tmux: &Arc<TmuxManager>,
350 capture: &Arc<CaptureService>,
351 channels: &Arc<ChannelRegistry>,
352 engine_refs: &[EngineRef],
353 channel_router: &Arc<ChannelRouter>,
354 pending_picks: &PendingPicks,
355) {
356 let topic_id = msg.topic_id.as_deref().unwrap_or(&msg.thread_id);
358 let resolved_repo = channel_router.resolve_project(&msg.channel, topic_id);
359 let is_general = channel_router.is_general(&msg.channel, topic_id);
360 let is_control = channel_router.is_control_channel(&msg.channel, topic_id);
361 let msg_topic_id = msg.topic_id.clone();
362
363 let route = normalize_channel_route(&msg, transport.route(&msg).await, is_control);
366
367 match route {
368 MessageRoute::TaskSession { session_key } => {
369 let body = msg.body.trim().to_string();
370 let channel = msg.channel.clone();
371 let thread_id = msg.thread_id.clone();
372
373 let (repo, task_id): (&str, &str) =
377 if let Some((r, t)) = transport::parse_session_key(&session_key) {
378 (r, t)
379 } else {
380 (resolved_repo.unwrap_or(""), &session_key)
382 };
383
384 if body.starts_with('/') {
385 if let Some(cmd) = parse_command(&body) {
387 let repos: Vec<&str> =
392 engine_refs.iter().map(|(r, _, _, _)| r.as_str()).collect();
393 let idx = transport.get_binding(repo, task_id).await.map_or(0, |b| {
394 engine_ref_idx_for_session(&repos, tmux, task_id, &b.tmux_session)
395 });
396 if let Some((repo, backend, task_manager, store)) = engine_refs.get(idx) {
397 let gh = match GhHttp::new() {
398 Ok(gh) => gh,
399 Err(e) => {
400 tracing::warn!(error = %e, "failed to build HTTP client for command execution");
401 send_channel_reply(
402 channels,
403 &channel,
404 &thread_id,
405 format!("Command `{cmd}` failed: {e}"),
406 msg_topic_id.as_deref(),
407 )
408 .await;
409 return;
410 }
411 };
412 let ext_id = ExternalId(task_id.to_string());
413 let store_ops: Option<Arc<dyn CommandStoreOps>> = store
414 .as_ref()
415 .map(|s| Arc::clone(s) as Arc<dyn CommandStoreOps>);
416 let result = execute_command(
417 backend,
418 &gh,
419 repo,
420 &ext_id,
421 &cmd,
422 &store_ops,
423 task_manager,
424 )
425 .await;
426 let reply = match result {
427 Ok(r) => r,
428 Err(e) => format!("Command `{cmd}` failed: {e}"),
429 };
430 send_channel_reply(
431 channels,
432 &channel,
433 &thread_id,
434 reply,
435 msg_topic_id.as_deref(),
436 )
437 .await;
438 }
439 } else {
440 forward_to_tmux(transport, repo, task_id, &body).await;
442 }
443 } else {
444 forward_to_tmux(transport, repo, task_id, &body).await;
446 }
447 }
448
449 MessageRoute::Command { raw } => {
450 let cmd_str = raw.trim().to_string();
451 let channel = msg.channel.clone();
452 let thread_id = msg.thread_id.clone();
453
454 if cmd_str == "/status" || cmd_str.starts_with("/status ") {
455 let reply = handle_status_command(engine_refs, resolved_repo).await;
457 send_channel_reply(
458 channels,
459 &channel,
460 &thread_id,
461 reply,
462 msg_topic_id.as_deref(),
463 )
464 .await;
465 } else if cmd_str == "/stats" || cmd_str.starts_with("/stats ") {
466 let reply = handle_stats_command(engine_refs, resolved_repo, is_general).await;
467 send_channel_reply(
468 channels,
469 &channel,
470 &thread_id,
471 reply,
472 msg_topic_id.as_deref(),
473 )
474 .await;
475 } else if cmd_str.starts_with("/subscribe") {
476 let reply = handle_subscribe_command(
477 &cmd_str,
478 &channel,
479 &thread_id,
480 msg_topic_id.as_deref(),
481 engine_refs,
482 channel_router,
483 )
484 .await;
485 send_channel_reply(
486 channels,
487 &channel,
488 &thread_id,
489 reply,
490 msg_topic_id.as_deref(),
491 )
492 .await;
493 } else if cmd_str.starts_with("/unsubscribe") {
494 let reply = handle_unsubscribe_command(
495 &cmd_str,
496 &channel,
497 &thread_id,
498 engine_refs,
499 channel_router,
500 )
501 .await;
502 send_channel_reply(
503 channels,
504 &channel,
505 &thread_id,
506 reply,
507 msg_topic_id.as_deref(),
508 )
509 .await;
510 } else if cmd_str.starts_with("/stream") {
511 let reply = handle_stream_command(
512 &cmd_str,
513 &channel,
514 &thread_id,
515 msg_topic_id.as_deref(),
516 transport,
517 engine_refs,
518 )
519 .await;
520 send_channel_reply(
521 channels,
522 &channel,
523 &thread_id,
524 reply,
525 msg_topic_id.as_deref(),
526 )
527 .await;
528 } else if let Some(cmd) = parse_command(&cmd_str) {
529 let reply = format!(
530 "Command `{cmd}` requires a task context. \
531 Send it in a thread that is bound to a running task."
532 );
533 send_channel_reply(
534 channels,
535 &channel,
536 &thread_id,
537 reply,
538 msg_topic_id.as_deref(),
539 )
540 .await;
541 } else {
542 let reply = "Available commands: /status, /stats, /subscribe, /unsubscribe, \
543 /stream, /retry, /close, /block, /unblock, /review — \
544 send task-specific commands in a task thread."
545 .to_string();
546 send_channel_reply(
547 channels,
548 &channel,
549 &thread_id,
550 reply,
551 msg_topic_id.as_deref(),
552 )
553 .await;
554 }
555 }
556
557 MessageRoute::ControlSession => {
558 let channel = msg.channel.clone();
559 let thread_id = msg.thread_id.clone();
560 let body = msg.body.clone();
561
562 let session_id = control_session_id(&channel, &thread_id, msg_topic_id.as_deref());
564 let channel_thread = conversation_key(&channel, &thread_id, msg_topic_id.as_deref());
565
566 let store = engine_refs
568 .iter()
569 .find_map(|(_, _, _, s)| s.as_ref())
570 .cloned();
571
572 if let Some(store) = store {
573 match control::send_message(
574 &store,
575 &session_id,
576 &channel,
577 Some(&channel_thread),
578 &body,
579 )
580 .await
581 {
582 Ok(reply) => {
583 send_channel_reply(
584 channels,
585 &channel,
586 &thread_id,
587 reply,
588 msg_topic_id.as_deref(),
589 )
590 .await;
591 }
592 Err(e) => {
593 tracing::warn!(error = %e, "control session agent invocation failed");
594 send_channel_reply(
595 channels,
596 &channel,
597 &thread_id,
598 format!("Control session error: {e}"),
599 msg_topic_id.as_deref(),
600 )
601 .await;
602 }
603 }
604 } else {
605 tracing::warn!("no store available, cannot handle control session message");
606 send_channel_reply(
607 channels,
608 &channel,
609 &thread_id,
610 "No projects configured. Please add a project with `orch project add` before using the control channel.".to_string(),
611 msg_topic_id.as_deref(),
612 )
613 .await;
614 }
615 }
616
617 MessageRoute::NewTask => {
618 let channel = msg.channel.clone();
619 let thread_id = msg.thread_id.clone();
620
621 if let Some((orig_id, repo)) = parse_pick_callback(msg.body.trim()) {
623 let pick_key = format!("{channel}:{orig_id}");
624 let original_body = {
625 let mut picks = pending_picks.lock().await;
626 picks
627 .remove(&pick_key)
628 .filter(|p| !p.is_expired())
629 .map(|p| p.original_body)
630 };
631 if let Some(body) = original_body {
632 ack_channel_interaction(channels, &channel, &msg.metadata).await;
634
635 create_and_announce_task(
636 &body,
637 Some(repo.as_str()),
638 &channel,
639 &thread_id,
640 msg_topic_id.as_deref(),
641 engine_refs,
642 transport,
643 tmux,
644 capture,
645 channels,
646 )
647 .await;
648 } else {
649 tracing::debug!(
650 pick_key,
651 "pick callback received but no matching pending pick (expired or unknown)"
652 );
653 send_channel_reply(
654 channels,
655 &channel,
656 &thread_id,
657 "Sorry, that selection has expired. Please send your message again."
658 .to_string(),
659 msg_topic_id.as_deref(),
660 )
661 .await;
662 }
663 return;
664 }
665
666 if resolved_repo.is_none() && engine_refs.len() > 1 {
668 let repos: Vec<&str> = engine_refs.iter().map(|(r, _, _, _)| r.as_str()).collect();
669 let buttons = buttons_for_picker(&repos, &msg.id);
670 let buttons_json: Vec<serde_json::Value> = buttons
671 .iter()
672 .map(|(text, cb)| serde_json::json!({ "text": text, "callback_data": cb }))
673 .collect();
674
675 {
677 let key = format!("{channel}:{}", msg.id);
678 let mut picks = pending_picks.lock().await;
679 picks.retain(|_, v| !v.is_expired());
681 picks.insert(key, PendingPick::new(msg.body.clone()));
682 }
683
684 let picker_msg = OutgoingMessage {
685 thread_id: thread_id.clone(),
686 body: "Which project should I create this task in?".to_string(),
687 reply_to: None,
688 metadata: serde_json::json!({ "buttons": buttons_json }),
689 topic_id: msg_topic_id.clone(),
690 };
691 for ch in channels.iter() {
692 if ch.name() == channel {
693 if let Err(e) = ch.send(&picker_msg).await {
694 tracing::warn!(channel, ?e, "failed to send project picker");
695 }
696 break;
697 }
698 }
699 return;
700 }
701
702 create_and_announce_task(
704 &msg.body,
705 resolved_repo,
706 &channel,
707 &thread_id,
708 msg_topic_id.as_deref(),
709 engine_refs,
710 transport,
711 tmux,
712 capture,
713 channels,
714 )
715 .await;
716 }
717 }
718}
719
720pub(super) async fn handle_status_command(
722 engine_refs: &[EngineRef],
723 resolved_repo: Option<&str>,
724) -> String {
725 let mut lines = vec!["**Active tasks:**".to_string()];
726 for (repo, _, task_manager, _) in engine_refs {
727 if let Some(target) = resolved_repo
729 && repo != target
730 {
731 continue;
732 }
733 match task_manager
734 .list_external_by_status(Status::InProgress)
735 .await
736 {
737 Ok(tasks) => {
738 for t in &tasks {
739 lines.push(format!("- #{} [{}]: {}", t.id.0, repo, t.title));
740 }
741 }
742 Err(e) => {
743 tracing::warn!(repo, err = %e, "failed to list in-progress tasks");
744 }
745 }
746 match task_manager
747 .list_internal_by_status(TaskStatus::InProgress)
748 .await
749 {
750 Ok(tasks) => {
751 for t in &tasks {
752 lines.push(format!("- #{} [{}]: {}", t.id.0, repo, t.title));
753 }
754 }
755 Err(e) => {
756 tracing::warn!(repo, err = %e, "failed to list in-progress internal tasks");
757 }
758 }
759 }
760 if lines.len() == 1 {
761 lines.push("No tasks currently in progress.".to_string());
762 }
763 lines.join("\n")
764}
765
766pub(super) async fn handle_stats_command(
768 engine_refs: &[EngineRef],
769 resolved_repo: Option<&str>,
770 is_general: bool,
771) -> String {
772 let mut lines = vec!["**Stats (24h)**".to_string(), String::new()];
773
774 let repos_to_query: Vec<&str> = if let Some(repo) = resolved_repo {
776 vec![repo]
777 } else {
778 engine_refs.iter().map(|(r, _, _, _)| r.as_str()).collect()
779 };
780
781 for repo in &repos_to_query {
782 let store = engine_refs
784 .iter()
785 .find(|(r, _, _, _)| r == repo)
786 .and_then(|(_, _, _, s)| s.as_ref());
787
788 let Some(store) = store else { continue };
789
790 match store.get_metrics_summary_by_repo(repo, 24).await {
791 Ok(summary) => {
792 let total = summary.tasks_completed_24h + summary.tasks_failed_24h;
793 #[expect(clippy::cast_precision_loss, reason = "a display percentage")]
794 let rate = if total > 0 {
795 (summary.tasks_completed_24h as f64 / total as f64) * 100.0
796 } else {
797 0.0
798 };
799 if is_general || resolved_repo.is_none() {
801 lines.push(format!(
802 "**{}**: {} done, {} failed ({:.1}%)",
803 repo, summary.tasks_completed_24h, summary.tasks_failed_24h, rate
804 ));
805 } else {
806 lines.push(format!(
807 "{} done, {} failed ({:.1}%)",
808 summary.tasks_completed_24h, summary.tasks_failed_24h, rate
809 ));
810 }
811 for agent in &summary.agent_stats {
813 lines.push(format!(
814 " {}: {} runs ({:.0}%)",
815 agent.agent, agent.total_runs, agent.success_rate
816 ));
817 }
818 if !summary.agent_stats.is_empty() {
819 lines.push(String::new());
820 }
821 }
822 Err(e) => {
823 tracing::warn!(repo, err = %e, "failed to query metrics");
824 lines.push(format!("**{repo}**: error fetching metrics"));
825 }
826 }
827 }
828
829 if lines.len() <= 2 {
830 lines.push("No metrics data available.".to_string());
831 }
832 lines.join("\n")
833}
834
835pub(super) async fn handle_subscribe_command(
837 cmd_str: &str,
838 channel: &str,
839 thread_id: &str,
840 topic_id: Option<&str>,
841 engine_refs: &[EngineRef],
842 channel_router: &Arc<ChannelRouter>,
843) -> String {
844 let parts: Vec<&str> = cmd_str.splitn(2, ' ').collect();
845 if parts.len() < 2 || parts[1].trim().is_empty() {
846 let projects: Vec<&str> = channel_router
847 .projects()
848 .iter()
849 .map(String::as_str)
850 .collect();
851 return format!(
852 "Usage: `/subscribe <project>`\nAvailable projects: {}",
853 projects.join(", ")
854 );
855 }
856 let project = parts[1].trim();
857
858 if !channel_router.projects().iter().any(|p| p == project) {
860 return format!(
861 "Unknown project `{project}`. Available: {}",
862 channel_router
863 .projects()
864 .iter()
865 .map(String::as_str)
866 .collect::<Vec<_>>()
867 .join(", ")
868 );
869 }
870
871 if let Some((_, _, _, Some(store))) = engine_refs.first() {
873 match store
874 .subscribe_channel(channel, thread_id, project, topic_id)
875 .await
876 {
877 Ok(()) => format!("Subscribed to notifications from `{project}`."),
878 Err(e) => format!("Failed to subscribe: {e}"),
879 }
880 } else {
881 "No store available for subscription management.".to_string()
882 }
883}
884
885pub(super) async fn handle_unsubscribe_command(
887 cmd_str: &str,
888 channel: &str,
889 thread_id: &str,
890 engine_refs: &[EngineRef],
891 channel_router: &Arc<ChannelRouter>,
892) -> String {
893 let parts: Vec<&str> = cmd_str.splitn(2, ' ').collect();
894 if parts.len() < 2 || parts[1].trim().is_empty() {
895 return "Usage: `/unsubscribe <project>`".to_string();
896 }
897 let project = parts[1].trim();
898
899 if !channel_router.projects().iter().any(|p| p == project) {
901 return format!("Unknown project `{project}`.");
902 }
903
904 if let Some((_, _, _, Some(store))) = engine_refs.first() {
905 match store.unsubscribe_channel(channel, thread_id, project).await {
906 Ok(()) => format!("Unsubscribed from `{project}` notifications."),
907 Err(e) => format!("Failed to unsubscribe: {e}"),
908 }
909 } else {
910 "No store available for subscription management.".to_string()
911 }
912}
913
914pub(super) async fn handle_stream_command(
916 cmd_str: &str,
917 channel: &str,
918 thread_id: &str,
919 topic_id: Option<&str>,
920 transport: &Arc<Transport>,
921 engine_refs: &[EngineRef],
922) -> String {
923 let parts: Vec<&str> = cmd_str.splitn(2, ' ').collect();
924 if parts.len() < 2 || parts[1].trim().is_empty() {
925 return "Usage: `/stream <task_id>`".to_string();
926 }
927 let task_id = parts[1].trim();
928
929 let repos: Vec<&str> = engine_refs.iter().map(|(r, _, _, _)| r.as_str()).collect();
932 let mut found = None;
933 for repo in &repos {
934 if let Some(binding) = transport.get_binding(repo, task_id).await {
935 found = Some((repo.to_string(), binding));
936 break;
937 }
938 }
939
940 if let Some((repo, binding)) = found {
941 transport
944 .bind(
945 &repo,
946 task_id,
947 &binding.tmux_session,
948 channel,
949 thread_id,
950 topic_id,
951 )
952 .await;
953 format!("Streaming output from task `{task_id}` to this channel.")
954 } else {
955 format!("Task `{task_id}` is not currently running or has no active session.")
956 }
957}
958
959#[cfg(test)]
960mod tests {
961 use super::*;
962 use std::time::{Duration, Instant};
963
964 #[test]
965 fn parse_pick_callback_parses_valid_input() {
966 let (orig_id, repo) = parse_pick_callback("pick:msg123:owner/repo").unwrap();
967 assert_eq!(orig_id, "msg123");
968 assert_eq!(repo, "owner/repo");
969 }
970
971 #[test]
972 fn parse_pick_callback_returns_none_for_non_pick_prefix() {
973 assert!(parse_pick_callback("not-a-pick").is_none());
974 assert!(parse_pick_callback("/command").is_none());
975 assert!(parse_pick_callback("").is_none());
976 }
977
978 #[test]
979 fn parse_pick_callback_returns_none_when_too_few_parts() {
980 assert!(parse_pick_callback("pick:").is_none());
982 assert!(parse_pick_callback("pick:only-one-part").is_none());
984 }
985
986 #[test]
987 fn parse_pick_callback_allows_slash_in_repo() {
988 let (orig_id, repo) = parse_pick_callback("pick:abc:owner/repo-name").unwrap();
989 assert_eq!(orig_id, "abc");
990 assert_eq!(repo, "owner/repo-name");
991 }
992
993 #[test]
994 fn pending_pick_is_expired_when_deadline_in_past() {
995 let pick = PendingPick {
996 original_body: "fix the login button".to_string(),
997 expires_at: Instant::now()
998 .checked_sub(Duration::from_secs(1))
999 .expect("the clock is past one second"),
1000 };
1001 assert!(pick.is_expired());
1002 }
1003
1004 #[test]
1005 fn pending_pick_is_not_expired_before_deadline() {
1006 let pick = PendingPick {
1007 original_body: "fix the login button".to_string(),
1008 expires_at: Instant::now() + Duration::from_secs(60),
1009 };
1010 assert!(!pick.is_expired());
1011 }
1012
1013 #[test]
1014 fn buttons_for_picker_generates_pick_callback_data() {
1015 let repos = ["owner/project-a", "owner/project-b"];
1016 let buttons = buttons_for_picker(&repos, "msg_123");
1017 assert_eq!(buttons.len(), 2);
1018 assert_eq!(
1019 buttons[0],
1020 (
1021 "project-a".to_string(),
1022 "pick:msg_123:owner/project-a".to_string()
1023 )
1024 );
1025 assert_eq!(
1026 buttons[1],
1027 (
1028 "project-b".to_string(),
1029 "pick:msg_123:owner/project-b".to_string()
1030 )
1031 );
1032 }
1033
1034 #[test]
1035 fn buttons_for_picker_uses_full_name_when_no_slash() {
1036 let repos = ["myrepo"];
1037 let buttons = buttons_for_picker(&repos, "id1");
1038 assert_eq!(buttons[0].0, "myrepo");
1039 assert_eq!(buttons[0].1, "pick:id1:myrepo");
1040 }
1041
1042 #[test]
1043 fn control_session_id_uses_conversation_key() {
1044 assert_eq!(
1045 control_session_id("telegram", "123", Some("456")),
1046 "control:telegram:123|456"
1047 );
1048 assert_eq!(
1049 control_session_id("telegram", "123", None),
1050 "control:telegram:123"
1051 );
1052 }
1053
1054 #[test]
1055 fn normalize_channel_route_promotes_telegram_new_task_to_control_session() {
1056 let msg = IncomingMessage {
1057 channel: "telegram".to_string(),
1058 id: "m1".to_string(),
1059 thread_id: "42".to_string(),
1060 author: "user".to_string(),
1061 body: "what's running?".to_string(),
1062 timestamp: chrono::Utc::now(),
1063 metadata: serde_json::json!({}),
1064 topic_id: None,
1065 };
1066
1067 assert!(matches!(
1068 normalize_channel_route(&msg, MessageRoute::NewTask, false),
1069 MessageRoute::ControlSession
1070 ));
1071 }
1072
1073 #[test]
1074 fn normalize_channel_route_preserves_picker_callbacks() {
1075 let msg = IncomingMessage {
1076 channel: "telegram".to_string(),
1077 id: "m1".to_string(),
1078 thread_id: "42".to_string(),
1079 author: "user".to_string(),
1080 body: "pick:m1:owner/repo".to_string(),
1081 timestamp: chrono::Utc::now(),
1082 metadata: serde_json::json!({}),
1083 topic_id: None,
1084 };
1085
1086 assert!(matches!(
1087 normalize_channel_route(&msg, MessageRoute::NewTask, false),
1088 MessageRoute::NewTask
1089 ));
1090 }
1091
1092 #[test]
1093 fn normalize_channel_route_preserves_non_telegram_new_task() {
1094 let msg = IncomingMessage {
1095 channel: "discord".to_string(),
1096 id: "m1".to_string(),
1097 thread_id: "42".to_string(),
1098 author: "user".to_string(),
1099 body: "create a task".to_string(),
1100 timestamp: chrono::Utc::now(),
1101 metadata: serde_json::json!({}),
1102 topic_id: None,
1103 };
1104
1105 assert!(matches!(
1106 normalize_channel_route(&msg, MessageRoute::NewTask, false),
1107 MessageRoute::NewTask
1108 ));
1109 }
1110
1111 #[test]
1118 fn engine_ref_idx_for_session_returns_owning_project() {
1119 let tmux = TmuxManager::new();
1120 let repos: Vec<&str> = vec!["owner/project-a", "owner/project-b"];
1121 let task_id = "42";
1122 let session_b = tmux.session_name("owner/project-b", task_id); let idx = engine_ref_idx_for_session(&repos, &tmux, task_id, &session_b);
1125 assert_eq!(
1126 idx, 1,
1127 "should resolve to project-b (index 1), not project-a"
1128 );
1129 }
1130
1131 #[test]
1134 fn engine_ref_idx_for_session_falls_back_to_zero_on_no_match() {
1135 let tmux = TmuxManager::new();
1136 let repos: Vec<&str> = vec!["owner/project-a", "owner/project-b"];
1137 let task_id = "42";
1138
1139 let idx = engine_ref_idx_for_session(&repos, &tmux, task_id, "orch-unknown-42");
1140 assert_eq!(
1141 idx, 0,
1142 "unknown session should fall back to first project (index 0)"
1143 );
1144 }
1145
1146 #[test]
1157 fn session_name_used_for_internal_task_matches_tmux_manager() {
1158 let tmux = TmuxManager::new();
1159 let repo = "owner/repo";
1160 let task_id = "internal:42";
1161
1162 let actual_session = tmux.session_name(repo, task_id);
1164
1165 let old_buggy_name = format!("orch-{repo}-{task_id}");
1169 assert_ne!(
1170 old_buggy_name, actual_session,
1171 "sanity check: old format should differ from sanitized name"
1172 );
1173
1174 assert!(
1176 actual_session.ends_with("repo-internal-42"),
1177 "internal task session must use the sanitized hyphenated id, got {actual_session}"
1178 );
1179 assert!(
1180 !actual_session.contains(':'),
1181 "tmux session names cannot contain a colon"
1182 );
1183 }
1184}