1pub mod chain;
49pub mod hosted;
50#[cfg(feature = "wake-claude")]
51pub mod claude_channel;
52#[cfg(not(feature = "wake-claude"))]
53pub use off::claude_channel;
54#[cfg(feature = "wake-codex")]
55pub mod codex;
56#[cfg(not(feature = "wake-codex"))]
57pub use off::codex;
58pub mod hook;
59pub mod inbox;
60#[cfg(feature = "wake-kimi")]
61pub mod kimi;
62pub mod kimi_acp;
63#[cfg(not(feature = "wake-kimi"))]
64pub use off::kimi;
65pub mod registry;
66#[cfg(feature = "wake-grok")]
67pub mod grok_leader;
68#[cfg(feature = "wake-routine")]
69pub mod routine;
70#[cfg(not(all(feature = "wake-claude", feature = "wake-codex", feature = "wake-kimi", feature = "wake-spawn")))]
71mod off;
72#[cfg(feature = "wake-spawn")]
73pub mod spawn;
74#[cfg(not(feature = "wake-spawn"))]
75pub use off::spawn;
76
77use std::fmt;
78use std::path::{Path, PathBuf};
79
80pub const LEADER_SOCK_ENV: &str = "M4A_LEADER_SOCK";
82
83pub const SEND_COMMAND: &str = "m4a-send";
85
86pub fn reply_hint(to: &str, from_nick: &str) -> String {
88 format!("{SEND_COMMAND} --as {to} --to {from_nick} '<your reply>'")
89}
90
91pub fn reply_hint_for(to: &str, from_nick: &str, room: Option<&str>) -> String {
95 match room.map(str::trim).filter(|r| r.starts_with('!') && !r.contains(char::is_whitespace) && !r.contains('\'')) {
96 Some(room) => format!("{ROOM_COMMAND} --as {to} send '{room}' '<your reply>'"),
97 None => reply_hint(to, from_nick),
98 }
99}
100
101pub const ROOM_COMMAND: &str = "m4a";
103
104
105pub use chain::{plan_chain, ChainError, HostEnv, Mechanism, Tier, WakeChain, WebVendor};
106pub use claude_channel::ClaudeChannelAdapter;
107pub use codex::{CodexAppServerAdapter, CodexEndpoint};
108pub use hook::{HookFlavor, InboxHookAdapter, InboxQueueAdapter};
109pub use inbox::{InboxEntry, InboxLetter};
110pub use kimi::KimiServerAdapter;
111pub use registry::SessionRecord;
112pub use spawn::ResumeSpawnAdapter;
113
114pub const PROVIDER_ENV: &str = "M4A_PROVIDER";
116
117pub const INBOX_DIR_ENV: &str = "M4A_INBOX_DIR";
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
124pub enum ProviderKind {
125 Grok,
127 KimiCode,
129 ClaudeCode,
131 Codex,
133 Cursor,
136}
137
138impl ProviderKind {
139 pub const CORE: [ProviderKind; 4] = [
141 ProviderKind::Grok,
142 ProviderKind::KimiCode,
143 ProviderKind::ClaudeCode,
144 ProviderKind::Codex,
145 ];
146
147 pub const ALL: [ProviderKind; 5] = [
149 ProviderKind::Grok,
150 ProviderKind::KimiCode,
151 ProviderKind::ClaudeCode,
152 ProviderKind::Codex,
153 ProviderKind::Cursor,
154 ];
155
156 pub fn id(self) -> &'static str {
158 match self {
159 ProviderKind::Grok => "grok",
160 ProviderKind::KimiCode => "kimi",
161 ProviderKind::ClaudeCode => "claude",
162 ProviderKind::Codex => "codex",
163 ProviderKind::Cursor => "cursor",
164 }
165 }
166
167 pub fn is_optional(self) -> bool {
169 matches!(self, ProviderKind::Cursor)
170 }
171
172 pub fn parse(value: &str) -> Option<Self> {
174 match value.trim().to_ascii_lowercase().as_str() {
175 "grok" => Some(ProviderKind::Grok),
176 "kimi" | "kimi-code" | "kimicode" => Some(ProviderKind::KimiCode),
177 "claude" | "claude-code" | "claudecode" => Some(ProviderKind::ClaudeCode),
178 "codex" => Some(ProviderKind::Codex),
179 "cursor" | "cursor-agent" => Some(ProviderKind::Cursor),
180 _ => None,
181 }
182 }
183
184 pub fn from_env() -> Result<Self, WakeError> {
186 match std::env::var(PROVIDER_ENV) {
187 Ok(value) if !value.trim().is_empty() => {
188 Self::parse(&value).ok_or(WakeError::UnknownProvider)
189 }
190 _ => Ok(ProviderKind::Grok),
191 }
192 }
193}
194
195impl fmt::Display for ProviderKind {
196 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
197 f.write_str(self.id())
198 }
199}
200
201#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
203pub enum Surface {
204 Web,
206 Local,
208}
209
210#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
212pub struct SessionKind {
213 pub provider: ProviderKind,
215 pub surface: Surface,
217}
218
219impl SessionKind {
220 pub fn local(provider: ProviderKind) -> Self {
222 Self {
223 provider,
224 surface: Surface::Local,
225 }
226 }
227
228 pub fn web(provider: ProviderKind) -> Self {
230 Self {
231 provider,
232 surface: Surface::Web,
233 }
234 }
235}
236
237impl fmt::Display for SessionKind {
238 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
239 let surface = match self.surface {
240 Surface::Web => "web",
241 Surface::Local => "local",
242 };
243 write!(f, "{}/{}", self.provider, surface)
244 }
245}
246
247#[derive(Debug, Clone, PartialEq, Eq)]
249pub struct ProviderSession {
250 pub kind: SessionKind,
252 pub session_id: String,
254 pub nick: String,
256 pub cwd: Option<PathBuf>,
258 pub headless: bool,
261}
262
263#[derive(Debug, Clone, Copy)]
265pub struct WakeLetter<'a> {
266 pub body: &'a str,
268 pub from_nick: &'a str,
270 pub event_id: &'a str,
272 pub room: Option<&'a str>,
274}
275
276#[derive(Debug, Clone, PartialEq, Eq)]
278pub enum WakeOutcome {
279 Delivered,
282 Queued(PathBuf),
285}
286
287#[derive(Debug, Clone, PartialEq, Eq)]
289pub enum WakeError {
290 UnknownProvider,
292 NotImplemented(SessionKind),
294 NoInboundTrigger(SessionKind),
296 Unavailable(String),
298 Transport(String),
300}
301
302impl fmt::Display for WakeError {
303 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
304 match self {
305 WakeError::UnknownProvider => write!(f, "unknown provider in {PROVIDER_ENV}"),
306 WakeError::NotImplemented(kind) => {
307 write!(f, "wake adapter for {kind} is not implemented")
308 }
309 WakeError::NoInboundTrigger(kind) => {
310 write!(
311 f,
312 "{kind} exposes no inbound trigger into a running session"
313 )
314 }
315 WakeError::Unavailable(why) => write!(f, "wake endpoint unavailable: {why}"),
316 WakeError::Transport(why) => write!(f, "wake failed: {why}"),
317 }
318 }
319}
320
321impl std::error::Error for WakeError {}
322
323pub trait WakeAdapter: Send {
329 fn kind(&self) -> SessionKind;
331
332 fn probe(&self, session: &ProviderSession) -> Result<(), WakeError>;
335
336 fn wake(
338 &mut self,
339 session: &ProviderSession,
340 letter: &WakeLetter<'_>,
341 ) -> Result<WakeOutcome, WakeError>;
342}
343
344pub fn wake_prompt(session: &ProviderSession, letter: &WakeLetter<'_>) -> String {
349 let reply = reply_hint_for(&session.nick, letter.from_nick, letter.room);
352 let mut head = format!(
353 "[mail4agent letter]\nfrom: {from}\nto: {to}\n",
354 from = letter.from_nick,
355 to = session.nick,
356 );
357 if let Some(room) = letter.room.map(str::trim).filter(|r| !r.is_empty()) {
358 head.push_str(&format!("room: {room}\n"));
359 }
360 format!("{head}event: {event}\nreply-command: {reply}\n---\n{body}", event = letter.event_id, body = letter.body)
361}
362
363#[derive(Default, Clone)]
367pub struct AdapterConfig {
368 pub leader_sock: Option<PathBuf>,
370 pub inbox_dir: Option<PathBuf>,
372 pub codex: Option<CodexEndpoint>,
374 pub kimi_url: Option<String>,
376 pub kimi_bearer: Option<String>,
378 pub kimi_acp_bin: Option<String>,
380 pub routine_url: Option<String>,
382 pub routine_bearer: Option<String>,
384 pub claude_fire_url: Option<String>,
386 pub claude_fire_bearer: Option<String>,
388 pub codex_cloud_env: Option<String>,
390 pub spawn_program: Option<PathBuf>,
392}
393
394pub const CLAUDE_FIRE_URL_ENV: &str = "M4A_CLAUDE_ROUTINE_FIRE_URL";
396pub const CLAUDE_FIRE_TOKEN_ENV: &str = "M4A_CLAUDE_ROUTINE_TOKEN";
398
399impl AdapterConfig {
400 pub fn from_env(inbox_dir: Option<PathBuf>) -> Self {
406 let get = |key: &str| std::env::var(key).ok().filter(|value| !value.is_empty());
407 let mut kimi = KimiServerAdapter::from_env();
408 if !kimi.is_configured() {
409 if let Some(found) = kimi::discover_local_server(None) {
410 kimi = found;
411 }
412 }
413 let (kimi_url, kimi_bearer) = kimi.into_parts();
414 Self {
415 leader_sock: get(crate::LEADER_SOCK_ENV).map(PathBuf::from),
416 inbox_dir: get(INBOX_DIR_ENV).map(PathBuf::from).or(inbox_dir),
417 codex: CodexEndpoint::from_env(),
418 kimi_url,
419 kimi_bearer,
420 kimi_acp_bin: kimi_acp::KimiAcpConfig::from_env().map(|c| c.bin),
421 routine_url: None,
422 routine_bearer: None,
423 claude_fire_url: get(CLAUDE_FIRE_URL_ENV),
424 claude_fire_bearer: get(CLAUDE_FIRE_TOKEN_ENV),
425 codex_cloud_env: get(spawn::CODEX_CLOUD_ENV_ENV),
426 spawn_program: None,
427 }
428 }
429}
430
431impl fmt::Debug for AdapterConfig {
432 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
433 f.debug_struct("AdapterConfig")
435 .field("leader_sock", &self.leader_sock.is_some())
436 .field("inbox_dir", &self.inbox_dir.is_some())
437 .field("codex", &self.codex.is_some())
438 .field("kimi_url", &self.kimi_url.is_some())
439 .field("routine_url", &self.routine_url.is_some())
440 .field("claude_fire_url", &self.claude_fire_url.is_some())
441 .field("codex_cloud_env", &self.codex_cloud_env.is_some())
442 .finish()
443 }
444}
445
446pub fn adapter_for(kind: SessionKind, config: &AdapterConfig) -> Box<dyn WakeAdapter> {
448 match (kind.surface, kind.provider) {
449 (Surface::Local, ProviderKind::Grok) => Box::new(GrokLeaderAdapter {
450 leader_sock: config.leader_sock.clone(),
451 }),
452 (Surface::Local, ProviderKind::Codex) => {
453 Box::new(CodexAppServerAdapter::new(config.codex.clone()))
454 }
455 (Surface::Local, ProviderKind::KimiCode) => {
456 if let Some(bin) = config.kimi_acp_bin.clone() {
458 Box::new(kimi_acp::KimiAcpAdapter::new(Some(kimi_acp::KimiAcpConfig { bin })))
459 } else {
460 Box::new(KimiServerAdapter::new(
461 config.kimi_url.clone(),
462 config.kimi_bearer.clone(),
463 ))
464 }
465 },
466 (Surface::Local, ProviderKind::ClaudeCode) => {
467 Box::new(ClaudeChannelAdapter::new(config.inbox_dir.clone()))
468 }
469 (Surface::Local, ProviderKind::Cursor) => Box::new(CursorAgentAdapter {
470 inbox_dir: config.inbox_dir.clone(),
471 }),
472 (Surface::Web, ProviderKind::Cursor) => Box::new(RoutineWebhookAdapter {
473 provider: ProviderKind::Cursor,
474 url: config.routine_url.clone(),
475 bearer: config.routine_bearer.clone(),
476 }),
477 (Surface::Web, ProviderKind::ClaudeCode) => Box::new(ClaudeRoutineFireAdapter {
478 url: config.claude_fire_url.clone(),
479 bearer: config.claude_fire_bearer.clone(),
480 }),
481 (Surface::Web, ProviderKind::Codex) => Box::new(CodexCloudAdapter),
482 (Surface::Web, provider @ (ProviderKind::KimiCode | ProviderKind::Grok)) => {
485 if config.routine_url.as_deref().is_some_and(|u| !u.is_empty()) {
486 Box::new(RoutineWebhookAdapter {
487 provider,
488 url: config.routine_url.clone(),
489 bearer: config.routine_bearer.clone(),
490 })
491 } else {
492 Box::new(NoInboundAdapter { provider })
493 }
494 }
495 }
496}
497
498pub struct GrokLeaderAdapter {
503 pub leader_sock: Option<PathBuf>,
505}
506
507impl WakeAdapter for GrokLeaderAdapter {
508 fn kind(&self) -> SessionKind {
509 SessionKind::local(ProviderKind::Grok)
510 }
511
512 fn probe(&self, _session: &ProviderSession) -> Result<(), WakeError> {
513 match self.leader_sock.as_deref() {
514 Some(path) if path.exists() => Ok(()),
515 Some(_) => Err(WakeError::Unavailable("leader socket missing".into())),
516 None => Err(WakeError::Unavailable("leader socket unset".into())),
517 }
518 }
519
520 fn wake(
521 &mut self,
522 session: &ProviderSession,
523 letter: &WakeLetter<'_>,
524 ) -> Result<WakeOutcome, WakeError> {
525 let _ = (&session, &letter);
526 #[cfg(not(feature = "wake-grok"))]
527 return Err(WakeError::Unavailable("this build was made without feature wake-grok".into()));
528 #[cfg(feature = "wake-grok")]
529 self.probe(session)?;
530 #[cfg(feature = "wake-grok")]
531 let Some(sock) = self.leader_sock.as_deref() else {
532 return Err(WakeError::Unavailable("leader socket unset".into()));
533 };
534 #[cfg(feature = "wake-grok")]
535 let cwd = session_cwd(session)?;
536 #[cfg(feature = "wake-grok")]
537 {
538 grok_leader::wake_decrypted_room_blocking(sock, &session.session_id, &cwd, &wake_prompt(session, letter))
539 .map(|()| WakeOutcome::Delivered)
540 .map_err(|e| WakeError::Transport(e.to_string()))
541 }
542 }
543}
544
545pub struct CursorAgentAdapter {
551 pub inbox_dir: Option<PathBuf>,
553}
554
555impl WakeAdapter for CursorAgentAdapter {
556 fn kind(&self) -> SessionKind {
557 SessionKind::local(ProviderKind::Cursor)
558 }
559
560 fn probe(&self, session: &ProviderSession) -> Result<(), WakeError> {
561 InboxHookAdapter::new(self.kind(), HookFlavor::CursorStop, self.inbox_dir.clone())
562 .probe(session)
563 }
564
565 fn wake(
566 &mut self,
567 session: &ProviderSession,
568 letter: &WakeLetter<'_>,
569 ) -> Result<WakeOutcome, WakeError> {
570 if !cfg!(feature = "wake-cursor") {
571 return Err(WakeError::Unavailable("this build was made without feature wake-cursor".into()));
572 }
573 InboxHookAdapter::new(self.kind(), HookFlavor::CursorStop, self.inbox_dir.clone())
574 .wake(session, letter)
575 }
576}
577
578pub struct RoutineWebhookAdapter {
584 pub provider: ProviderKind,
586 pub url: Option<String>,
588 pub bearer: Option<String>,
590}
591
592impl WakeAdapter for RoutineWebhookAdapter {
593 fn kind(&self) -> SessionKind {
594 SessionKind::web(self.provider)
595 }
596
597 fn probe(&self, _session: &ProviderSession) -> Result<(), WakeError> {
598 match self.url.as_deref() {
599 Some(url) if !url.is_empty() => Ok(()),
600 _ => Err(WakeError::Unavailable("routine url unset".into())),
601 }
602 }
603
604 fn wake(
605 &mut self,
606 session: &ProviderSession,
607 letter: &WakeLetter<'_>,
608 ) -> Result<WakeOutcome, WakeError> {
609 if !cfg!(feature = "wake-routine") {
610 return Err(WakeError::Unavailable("this build was made without feature wake-routine".into()));
611 }
612 self.probe(session)?;
613 let url = self.url.as_deref().unwrap_or_default();
614 let reply = reply_hint_for(&session.nick, letter.from_nick, letter.room);
615 let mut body = serde_json::Map::new();
616 for (k, v) in [
617 ("body", Some(letter.body)),
618 ("from", Some(letter.from_nick)),
619 ("event_id", Some(letter.event_id)),
620 ("room", letter.room),
621 ("from_nick", Some(letter.from_nick)),
622 ("to", Some(session.nick.as_str())),
623 ("reply", Some(reply.as_str())),
624 ] {
625 if let Some(v) = v.map(str::trim).filter(|v| !v.is_empty() || k == "body") {
626 body.insert(k.into(), serde_json::Value::String(v.into()));
627 }
628 }
629 #[cfg(not(feature = "wake-routine"))]
630 {
631 let _ = (url, body);
632 return Err(WakeError::Unavailable("this build was made without feature wake-routine".into()));
633 }
634 #[cfg(feature = "wake-routine")]
635 routine::post_routine_json(url, &serde_json::Value::Object(body), self.bearer.as_deref())
636 .map(|()| WakeOutcome::Delivered)
637 .map_err(|e| WakeError::Transport(e.to_string()))
638 }
639}
640
641pub struct ClaudeRoutineFireAdapter {
649 pub url: Option<String>,
651 pub bearer: Option<String>,
653}
654
655pub const CLAUDE_ROUTINE_BETA: &str = "experimental-cc-routine-2026-04-01";
657
658impl WakeAdapter for ClaudeRoutineFireAdapter {
659 fn kind(&self) -> SessionKind {
660 SessionKind::web(ProviderKind::ClaudeCode)
661 }
662
663 fn probe(&self, session: &ProviderSession) -> Result<(), WakeError> {
664 if self.url.as_deref().is_none_or(str::is_empty) {
665 return Err(WakeError::Unavailable(
666 "claude routine fire url unset".into(),
667 ));
668 }
669 if self.bearer.as_deref().is_none_or(str::is_empty) {
670 return Err(WakeError::Unavailable("claude routine token unset".into()));
671 }
672 if !session.headless {
673 return Err(WakeError::Unavailable(
674 "session is open; routine fire would start a new session".into(),
675 ));
676 }
677 Ok(())
678 }
679
680 fn wake(
681 &mut self,
682 session: &ProviderSession,
683 letter: &WakeLetter<'_>,
684 ) -> Result<WakeOutcome, WakeError> {
685 if !cfg!(feature = "wake-claude") {
686 return Err(WakeError::Unavailable("this build was made without feature wake-claude".into()));
687 }
688 self.probe(session)?;
689 let url = self.url.as_deref().unwrap_or_default();
690 let token = self.bearer.as_deref().unwrap_or_default();
691 let parsed = reqwest::Url::parse(url)
692 .ok()
693 .filter(|u| u.scheme() == "https" || is_loopback_http(u))
694 .ok_or_else(|| WakeError::Unavailable("claude routine fire url invalid".into()))?;
695 let beta = std::env::var("M4A_CLAUDE_ROUTINE_BETA")
696 .ok()
697 .filter(|v| !v.is_empty())
698 .unwrap_or_else(|| CLAUDE_ROUTINE_BETA.to_string());
699 let client = reqwest::blocking::Client::builder()
700 .timeout(std::time::Duration::from_secs(15))
701 .build()
702 .map_err(|err| WakeError::Transport(err.without_url().to_string()))?;
703 let response = client
704 .post(parsed)
705 .bearer_auth(token)
706 .header("anthropic-version", "2023-06-01")
707 .header("anthropic-beta", beta)
708 .json(&serde_json::json!({"text": wake_prompt(session, letter)}))
709 .send()
710 .map_err(|err| WakeError::Transport(err.without_url().to_string()))?;
711 if response.status().is_success() {
712 Ok(WakeOutcome::Delivered)
713 } else {
714 Err(WakeError::Transport(format!(
715 "routine fire status {}",
716 response.status().as_u16()
717 )))
718 }
719 }
720}
721
722fn is_loopback_http(url: &reqwest::Url) -> bool {
723 url.scheme() == "http"
724 && matches!(
725 url.host_str(),
726 Some("127.0.0.1" | "localhost" | "[::1]" | "::1")
727 )
728}
729
730pub struct CodexCloudAdapter;
735
736pub struct NoInboundAdapter {
738 pub provider: ProviderKind,
740}
741
742macro_rules! refuse_adapter {
743 ($ty:ty, $kind:expr, $err:ident) => {
744 impl WakeAdapter for $ty {
745 fn kind(&self) -> SessionKind {
746 $kind(self)
747 }
748
749 fn probe(&self, _session: &ProviderSession) -> Result<(), WakeError> {
750 Err(WakeError::$err(self.kind()))
751 }
752
753 fn wake(
754 &mut self,
755 _session: &ProviderSession,
756 _letter: &WakeLetter<'_>,
757 ) -> Result<WakeOutcome, WakeError> {
758 Err(WakeError::$err(self.kind()))
759 }
760 }
761 };
762}
763
764refuse_adapter!(
765 CodexCloudAdapter,
766 |_: &CodexCloudAdapter| SessionKind::web(ProviderKind::Codex),
767 NotImplemented
768);
769refuse_adapter!(
770 NoInboundAdapter,
771 |adapter: &NoInboundAdapter| SessionKind::web(adapter.provider),
772 NoInboundTrigger
773);
774
775#[cfg_attr(not(feature = "wake-grok"), allow(dead_code))]
776pub(crate) fn session_cwd(session: &ProviderSession) -> Result<String, WakeError> {
777 session
778 .cwd
779 .as_deref()
780 .map(Path::to_path_buf)
781 .or_else(|| std::env::current_dir().ok())
782 .map(|path| path.display().to_string())
783 .filter(|cwd| !cwd.is_empty())
784 .ok_or_else(|| WakeError::Unavailable("session cwd is empty".into()))
785}
786
787#[cfg(test)]
788pub(crate) mod tests {
789 use super::*;
790
791 pub(crate) fn session(kind: SessionKind) -> ProviderSession {
792 ProviderSession {
793 kind,
794 session_id: "s-1".into(),
795 nick: "alice".into(),
796 cwd: Some(PathBuf::from("/tmp")),
797 headless: false,
798 }
799 }
800
801 pub(crate) fn letter<'a>(body: &'a str) -> WakeLetter<'a> {
802 WakeLetter {
803 body,
804 from_nick: "carol",
805 event_id: "$ev1",
806 room: None,
807 }
808 }
809
810 #[test]
811 fn web_grok_uses_the_routine_only_when_one_is_configured() {
812 let kind = SessionKind::web(ProviderKind::Grok);
813 let none = adapter_for(kind, &AdapterConfig::default());
814 assert!(none.probe(&session(kind)).is_err());
815 let cfg = AdapterConfig { routine_url: Some("http://127.0.0.1:9/hook".into()), ..Default::default() };
816 let routine = adapter_for(kind, &cfg);
817 assert!(routine.probe(&session(kind)).is_ok());
818 }
819
820 #[test]
821 fn ids_round_trip() {
822 for kind in ProviderKind::ALL {
823 assert_eq!(ProviderKind::parse(kind.id()), Some(kind));
824 }
825 assert_eq!(
826 ProviderKind::parse("Claude-Code"),
827 Some(ProviderKind::ClaudeCode)
828 );
829 assert_eq!(ProviderKind::parse("gemini"), None);
830 assert!(ProviderKind::CORE.iter().all(|kind| !kind.is_optional()));
831 assert!(ProviderKind::Cursor.is_optional());
832 assert_eq!(
833 SessionKind::web(ProviderKind::Codex).to_string(),
834 "codex/web"
835 );
836 }
837
838 #[test]
839 fn every_kind_gets_an_adapter_of_that_kind() {
840 let config = AdapterConfig::default();
841 for provider in ProviderKind::ALL {
842 for kind in [SessionKind::local(provider), SessionKind::web(provider)] {
843 assert_eq!(adapter_for(kind, &config).kind(), kind);
844 }
845 }
846 }
847
848 #[test]
849 fn stubs_and_unconfigured_adapters_refuse_without_side_effects() {
850 let config = AdapterConfig::default();
851 let cases = [
852 (SessionKind::local(ProviderKind::Cursor), "unavailable"),
853 (SessionKind::web(ProviderKind::ClaudeCode), "unavailable"),
854 (SessionKind::web(ProviderKind::Codex), "not implemented"),
855 (SessionKind::web(ProviderKind::KimiCode), "no inbound"),
856 (SessionKind::web(ProviderKind::Grok), "no inbound"),
857 (SessionKind::web(ProviderKind::Cursor), "unavailable"),
858 (SessionKind::local(ProviderKind::Grok), "unavailable"),
859 (SessionKind::local(ProviderKind::Codex), "unavailable"),
860 (SessionKind::local(ProviderKind::KimiCode), "unavailable"),
861 (SessionKind::local(ProviderKind::ClaudeCode), "unavailable"),
862 ];
863 for (kind, expect) in cases {
864 let mut adapter = adapter_for(kind, &config);
865 let s = session(kind);
866 let err = adapter.wake(&s, &letter("x")).unwrap_err().to_string();
867 assert!(err.contains(expect), "{kind}: {err}");
868 }
869 }
870
871 #[test]
872 fn room_letters_are_answered_in_the_room() {
873 assert_eq!(reply_hint_for("alice", "carol", Some("!r1:example.org")), "m4a --as alice send '!r1:example.org' '<your reply>'");
874 assert_eq!(reply_hint_for("alice", "carol", None), reply_hint("alice", "carol"));
875 assert_eq!(reply_hint_for("alice", "carol", Some("bad room")), reply_hint("alice", "carol"));
876 }
877
878 #[test]
879 fn prompt_carries_reply_command_and_body() {
880 let text = wake_prompt(
881 &session(SessionKind::local(ProviderKind::Codex)),
882 &letter("ping"),
883 );
884 assert!(text.contains("reply-command: m4a-send --as alice --to carol"));
885 assert!(text.ends_with("---\nping"));
886 assert!(!text.contains("Answer every") && !text.contains("принято"), "no forced instruction");
887 }
888
889 #[test]
890 fn config_debug_hides_values() {
891 let config = AdapterConfig {
892 kimi_bearer: Some("k-secret".into()),
893 routine_url: Some("https://example.invalid/hook".into()),
894 ..AdapterConfig::default()
895 };
896 let shown = format!("{config:?}");
897 assert!(!shown.contains("secret") && !shown.contains("example"));
898 }
899}
900
901#[cfg(test)]
902mod g4a_alignment_tests {
903 use super::ProviderKind;
904
905 #[test]
908 fn names_hatchery_node_sends_all_parse() {
909 for (sent, kind) in [
910 ("claude-code", ProviderKind::ClaudeCode),
911 ("codex", ProviderKind::Codex),
912 ("grok", ProviderKind::Grok),
913 ("kimi-code", ProviderKind::KimiCode),
914 ("claude", ProviderKind::ClaudeCode),
915 ("kimi", ProviderKind::KimiCode),
916 ] {
917 assert_eq!(ProviderKind::parse(sent), Some(kind), "{sent}");
918 }
919 assert_eq!(ProviderKind::parse("custom"), None);
920 }
921}