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