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
91
92pub use chain::{plan_chain, ChainError, HostEnv, Mechanism, Tier, WakeChain, WebVendor};
93pub use claude_channel::ClaudeChannelAdapter;
94pub use codex::{CodexAppServerAdapter, CodexEndpoint};
95pub use hook::{HookFlavor, InboxHookAdapter, InboxQueueAdapter};
96pub use inbox::{InboxEntry, InboxLetter};
97pub use kimi::KimiServerAdapter;
98pub use registry::SessionRecord;
99pub use spawn::ResumeSpawnAdapter;
100
101pub const PROVIDER_ENV: &str = "M4A_PROVIDER";
103
104pub const INBOX_DIR_ENV: &str = "M4A_INBOX_DIR";
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
111pub enum ProviderKind {
112 Grok,
114 KimiCode,
116 ClaudeCode,
118 Codex,
120 Cursor,
123}
124
125impl ProviderKind {
126 pub const CORE: [ProviderKind; 4] = [
128 ProviderKind::Grok,
129 ProviderKind::KimiCode,
130 ProviderKind::ClaudeCode,
131 ProviderKind::Codex,
132 ];
133
134 pub const ALL: [ProviderKind; 5] = [
136 ProviderKind::Grok,
137 ProviderKind::KimiCode,
138 ProviderKind::ClaudeCode,
139 ProviderKind::Codex,
140 ProviderKind::Cursor,
141 ];
142
143 pub fn id(self) -> &'static str {
145 match self {
146 ProviderKind::Grok => "grok",
147 ProviderKind::KimiCode => "kimi",
148 ProviderKind::ClaudeCode => "claude",
149 ProviderKind::Codex => "codex",
150 ProviderKind::Cursor => "cursor",
151 }
152 }
153
154 pub fn is_optional(self) -> bool {
156 matches!(self, ProviderKind::Cursor)
157 }
158
159 pub fn parse(value: &str) -> Option<Self> {
161 match value.trim().to_ascii_lowercase().as_str() {
162 "grok" => Some(ProviderKind::Grok),
163 "kimi" | "kimi-code" | "kimicode" => Some(ProviderKind::KimiCode),
164 "claude" | "claude-code" | "claudecode" => Some(ProviderKind::ClaudeCode),
165 "codex" => Some(ProviderKind::Codex),
166 "cursor" | "cursor-agent" => Some(ProviderKind::Cursor),
167 _ => None,
168 }
169 }
170
171 pub fn from_env() -> Result<Self, WakeError> {
173 match std::env::var(PROVIDER_ENV) {
174 Ok(value) if !value.trim().is_empty() => {
175 Self::parse(&value).ok_or(WakeError::UnknownProvider)
176 }
177 _ => Ok(ProviderKind::Grok),
178 }
179 }
180}
181
182impl fmt::Display for ProviderKind {
183 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
184 f.write_str(self.id())
185 }
186}
187
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
190pub enum Surface {
191 Web,
193 Local,
195}
196
197#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
199pub struct SessionKind {
200 pub provider: ProviderKind,
202 pub surface: Surface,
204}
205
206impl SessionKind {
207 pub fn local(provider: ProviderKind) -> Self {
209 Self {
210 provider,
211 surface: Surface::Local,
212 }
213 }
214
215 pub fn web(provider: ProviderKind) -> Self {
217 Self {
218 provider,
219 surface: Surface::Web,
220 }
221 }
222}
223
224impl fmt::Display for SessionKind {
225 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
226 let surface = match self.surface {
227 Surface::Web => "web",
228 Surface::Local => "local",
229 };
230 write!(f, "{}/{}", self.provider, surface)
231 }
232}
233
234#[derive(Debug, Clone, PartialEq, Eq)]
236pub struct ProviderSession {
237 pub kind: SessionKind,
239 pub session_id: String,
241 pub nick: String,
243 pub cwd: Option<PathBuf>,
245 pub headless: bool,
248}
249
250#[derive(Debug, Clone, Copy)]
252pub struct WakeLetter<'a> {
253 pub body: &'a str,
255 pub from_nick: &'a str,
257 pub event_id: &'a str,
259 pub room: Option<&'a str>,
261}
262
263#[derive(Debug, Clone, PartialEq, Eq)]
265pub enum WakeOutcome {
266 Delivered,
269 Queued(PathBuf),
272}
273
274#[derive(Debug, Clone, PartialEq, Eq)]
276pub enum WakeError {
277 UnknownProvider,
279 NotImplemented(SessionKind),
281 NoInboundTrigger(SessionKind),
283 Unavailable(String),
285 Transport(String),
287}
288
289impl fmt::Display for WakeError {
290 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
291 match self {
292 WakeError::UnknownProvider => write!(f, "unknown provider in {PROVIDER_ENV}"),
293 WakeError::NotImplemented(kind) => {
294 write!(f, "wake adapter for {kind} is not implemented")
295 }
296 WakeError::NoInboundTrigger(kind) => {
297 write!(
298 f,
299 "{kind} exposes no inbound trigger into a running session"
300 )
301 }
302 WakeError::Unavailable(why) => write!(f, "wake endpoint unavailable: {why}"),
303 WakeError::Transport(why) => write!(f, "wake failed: {why}"),
304 }
305 }
306}
307
308impl std::error::Error for WakeError {}
309
310pub trait WakeAdapter: Send {
316 fn kind(&self) -> SessionKind;
318
319 fn probe(&self, session: &ProviderSession) -> Result<(), WakeError>;
322
323 fn wake(
325 &mut self,
326 session: &ProviderSession,
327 letter: &WakeLetter<'_>,
328 ) -> Result<WakeOutcome, WakeError>;
329}
330
331pub fn wake_prompt(session: &ProviderSession, letter: &WakeLetter<'_>) -> String {
336 let reply = reply_hint(&session.nick, letter.from_nick);
339 let mut head = format!(
340 "[mail4agent letter]\nfrom: {from}\nto: {to}\n",
341 from = letter.from_nick,
342 to = session.nick,
343 );
344 if let Some(room) = letter.room.map(str::trim).filter(|r| !r.is_empty()) {
345 head.push_str(&format!("room: {room}\n"));
346 }
347 format!("{head}event: {event}\nreply-command: {reply}\n---\n{body}", event = letter.event_id, body = letter.body)
348}
349
350#[derive(Default, Clone)]
354pub struct AdapterConfig {
355 pub leader_sock: Option<PathBuf>,
357 pub inbox_dir: Option<PathBuf>,
359 pub codex: Option<CodexEndpoint>,
361 pub kimi_url: Option<String>,
363 pub kimi_bearer: Option<String>,
365 pub kimi_acp_bin: Option<String>,
367 pub routine_url: Option<String>,
369 pub routine_bearer: Option<String>,
371 pub claude_fire_url: Option<String>,
373 pub claude_fire_bearer: Option<String>,
375 pub codex_cloud_env: Option<String>,
377 pub spawn_program: Option<PathBuf>,
379}
380
381pub const CLAUDE_FIRE_URL_ENV: &str = "M4A_CLAUDE_ROUTINE_FIRE_URL";
383pub const CLAUDE_FIRE_TOKEN_ENV: &str = "M4A_CLAUDE_ROUTINE_TOKEN";
385
386impl AdapterConfig {
387 pub fn from_env(inbox_dir: Option<PathBuf>) -> Self {
393 let get = |key: &str| std::env::var(key).ok().filter(|value| !value.is_empty());
394 let mut kimi = KimiServerAdapter::from_env();
395 if !kimi.is_configured() {
396 if let Some(found) = kimi::discover_local_server(None) {
397 kimi = found;
398 }
399 }
400 let (kimi_url, kimi_bearer) = kimi.into_parts();
401 Self {
402 leader_sock: get(crate::LEADER_SOCK_ENV).map(PathBuf::from),
403 inbox_dir: get(INBOX_DIR_ENV).map(PathBuf::from).or(inbox_dir),
404 codex: CodexEndpoint::from_env(),
405 kimi_url,
406 kimi_bearer,
407 kimi_acp_bin: kimi_acp::KimiAcpConfig::from_env().map(|c| c.bin),
408 routine_url: None,
409 routine_bearer: None,
410 claude_fire_url: get(CLAUDE_FIRE_URL_ENV),
411 claude_fire_bearer: get(CLAUDE_FIRE_TOKEN_ENV),
412 codex_cloud_env: get(spawn::CODEX_CLOUD_ENV_ENV),
413 spawn_program: None,
414 }
415 }
416}
417
418impl fmt::Debug for AdapterConfig {
419 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
420 f.debug_struct("AdapterConfig")
422 .field("leader_sock", &self.leader_sock.is_some())
423 .field("inbox_dir", &self.inbox_dir.is_some())
424 .field("codex", &self.codex.is_some())
425 .field("kimi_url", &self.kimi_url.is_some())
426 .field("routine_url", &self.routine_url.is_some())
427 .field("claude_fire_url", &self.claude_fire_url.is_some())
428 .field("codex_cloud_env", &self.codex_cloud_env.is_some())
429 .finish()
430 }
431}
432
433pub fn adapter_for(kind: SessionKind, config: &AdapterConfig) -> Box<dyn WakeAdapter> {
435 match (kind.surface, kind.provider) {
436 (Surface::Local, ProviderKind::Grok) => Box::new(GrokLeaderAdapter {
437 leader_sock: config.leader_sock.clone(),
438 }),
439 (Surface::Local, ProviderKind::Codex) => {
440 Box::new(CodexAppServerAdapter::new(config.codex.clone()))
441 }
442 (Surface::Local, ProviderKind::KimiCode) => {
443 if let Some(bin) = config.kimi_acp_bin.clone() {
445 Box::new(kimi_acp::KimiAcpAdapter::new(Some(kimi_acp::KimiAcpConfig { bin })))
446 } else {
447 Box::new(KimiServerAdapter::new(
448 config.kimi_url.clone(),
449 config.kimi_bearer.clone(),
450 ))
451 }
452 },
453 (Surface::Local, ProviderKind::ClaudeCode) => {
454 Box::new(ClaudeChannelAdapter::new(config.inbox_dir.clone()))
455 }
456 (Surface::Local, ProviderKind::Cursor) => Box::new(CursorAgentAdapter {
457 inbox_dir: config.inbox_dir.clone(),
458 }),
459 (Surface::Web, ProviderKind::Cursor) => Box::new(RoutineWebhookAdapter {
460 provider: ProviderKind::Cursor,
461 url: config.routine_url.clone(),
462 bearer: config.routine_bearer.clone(),
463 }),
464 (Surface::Web, ProviderKind::ClaudeCode) => Box::new(ClaudeRoutineFireAdapter {
465 url: config.claude_fire_url.clone(),
466 bearer: config.claude_fire_bearer.clone(),
467 }),
468 (Surface::Web, ProviderKind::Codex) => Box::new(CodexCloudAdapter),
469 (Surface::Web, provider @ (ProviderKind::KimiCode | ProviderKind::Grok)) => {
472 if config.routine_url.as_deref().is_some_and(|u| !u.is_empty()) {
473 Box::new(RoutineWebhookAdapter {
474 provider,
475 url: config.routine_url.clone(),
476 bearer: config.routine_bearer.clone(),
477 })
478 } else {
479 Box::new(NoInboundAdapter { provider })
480 }
481 }
482 }
483}
484
485pub struct GrokLeaderAdapter {
490 pub leader_sock: Option<PathBuf>,
492}
493
494impl WakeAdapter for GrokLeaderAdapter {
495 fn kind(&self) -> SessionKind {
496 SessionKind::local(ProviderKind::Grok)
497 }
498
499 fn probe(&self, _session: &ProviderSession) -> Result<(), WakeError> {
500 match self.leader_sock.as_deref() {
501 Some(path) if path.exists() => Ok(()),
502 Some(_) => Err(WakeError::Unavailable("leader socket missing".into())),
503 None => Err(WakeError::Unavailable("leader socket unset".into())),
504 }
505 }
506
507 fn wake(
508 &mut self,
509 session: &ProviderSession,
510 letter: &WakeLetter<'_>,
511 ) -> Result<WakeOutcome, WakeError> {
512 let _ = (&session, &letter);
513 #[cfg(not(feature = "wake-grok"))]
514 return Err(WakeError::Unavailable("this build was made without feature wake-grok".into()));
515 #[cfg(feature = "wake-grok")]
516 self.probe(session)?;
517 #[cfg(feature = "wake-grok")]
518 let Some(sock) = self.leader_sock.as_deref() else {
519 return Err(WakeError::Unavailable("leader socket unset".into()));
520 };
521 #[cfg(feature = "wake-grok")]
522 let cwd = session_cwd(session)?;
523 #[cfg(feature = "wake-grok")]
524 {
525 grok_leader::wake_decrypted_room_blocking(sock, &session.session_id, &cwd, &wake_prompt(session, letter))
526 .map(|()| WakeOutcome::Delivered)
527 .map_err(|e| WakeError::Transport(e.to_string()))
528 }
529 }
530}
531
532pub struct CursorAgentAdapter {
538 pub inbox_dir: Option<PathBuf>,
540}
541
542impl WakeAdapter for CursorAgentAdapter {
543 fn kind(&self) -> SessionKind {
544 SessionKind::local(ProviderKind::Cursor)
545 }
546
547 fn probe(&self, session: &ProviderSession) -> Result<(), WakeError> {
548 InboxHookAdapter::new(self.kind(), HookFlavor::CursorStop, self.inbox_dir.clone())
549 .probe(session)
550 }
551
552 fn wake(
553 &mut self,
554 session: &ProviderSession,
555 letter: &WakeLetter<'_>,
556 ) -> Result<WakeOutcome, WakeError> {
557 if !cfg!(feature = "wake-cursor") {
558 return Err(WakeError::Unavailable("this build was made without feature wake-cursor".into()));
559 }
560 InboxHookAdapter::new(self.kind(), HookFlavor::CursorStop, self.inbox_dir.clone())
561 .wake(session, letter)
562 }
563}
564
565pub struct RoutineWebhookAdapter {
571 pub provider: ProviderKind,
573 pub url: Option<String>,
575 pub bearer: Option<String>,
577}
578
579impl WakeAdapter for RoutineWebhookAdapter {
580 fn kind(&self) -> SessionKind {
581 SessionKind::web(self.provider)
582 }
583
584 fn probe(&self, _session: &ProviderSession) -> Result<(), WakeError> {
585 match self.url.as_deref() {
586 Some(url) if !url.is_empty() => Ok(()),
587 _ => Err(WakeError::Unavailable("routine url unset".into())),
588 }
589 }
590
591 fn wake(
592 &mut self,
593 session: &ProviderSession,
594 letter: &WakeLetter<'_>,
595 ) -> Result<WakeOutcome, WakeError> {
596 if !cfg!(feature = "wake-routine") {
597 return Err(WakeError::Unavailable("this build was made without feature wake-routine".into()));
598 }
599 self.probe(session)?;
600 let url = self.url.as_deref().unwrap_or_default();
601 let reply = reply_hint(&session.nick, letter.from_nick);
602 let mut body = serde_json::Map::new();
603 for (k, v) in [
604 ("body", Some(letter.body)),
605 ("from", Some(letter.from_nick)),
606 ("event_id", Some(letter.event_id)),
607 ("room", letter.room),
608 ("from_nick", Some(letter.from_nick)),
609 ("to", Some(session.nick.as_str())),
610 ("reply", Some(reply.as_str())),
611 ] {
612 if let Some(v) = v.map(str::trim).filter(|v| !v.is_empty() || k == "body") {
613 body.insert(k.into(), serde_json::Value::String(v.into()));
614 }
615 }
616 #[cfg(not(feature = "wake-routine"))]
617 {
618 let _ = (url, body);
619 return Err(WakeError::Unavailable("this build was made without feature wake-routine".into()));
620 }
621 #[cfg(feature = "wake-routine")]
622 routine::post_routine_json(url, &serde_json::Value::Object(body), self.bearer.as_deref())
623 .map(|()| WakeOutcome::Delivered)
624 .map_err(|e| WakeError::Transport(e.to_string()))
625 }
626}
627
628pub struct ClaudeRoutineFireAdapter {
636 pub url: Option<String>,
638 pub bearer: Option<String>,
640}
641
642pub const CLAUDE_ROUTINE_BETA: &str = "experimental-cc-routine-2026-04-01";
644
645impl WakeAdapter for ClaudeRoutineFireAdapter {
646 fn kind(&self) -> SessionKind {
647 SessionKind::web(ProviderKind::ClaudeCode)
648 }
649
650 fn probe(&self, session: &ProviderSession) -> Result<(), WakeError> {
651 if self.url.as_deref().is_none_or(str::is_empty) {
652 return Err(WakeError::Unavailable(
653 "claude routine fire url unset".into(),
654 ));
655 }
656 if self.bearer.as_deref().is_none_or(str::is_empty) {
657 return Err(WakeError::Unavailable("claude routine token unset".into()));
658 }
659 if !session.headless {
660 return Err(WakeError::Unavailable(
661 "session is open; routine fire would start a new session".into(),
662 ));
663 }
664 Ok(())
665 }
666
667 fn wake(
668 &mut self,
669 session: &ProviderSession,
670 letter: &WakeLetter<'_>,
671 ) -> Result<WakeOutcome, WakeError> {
672 if !cfg!(feature = "wake-claude") {
673 return Err(WakeError::Unavailable("this build was made without feature wake-claude".into()));
674 }
675 self.probe(session)?;
676 let url = self.url.as_deref().unwrap_or_default();
677 let token = self.bearer.as_deref().unwrap_or_default();
678 let parsed = reqwest::Url::parse(url)
679 .ok()
680 .filter(|u| u.scheme() == "https" || is_loopback_http(u))
681 .ok_or_else(|| WakeError::Unavailable("claude routine fire url invalid".into()))?;
682 let beta = std::env::var("M4A_CLAUDE_ROUTINE_BETA")
683 .ok()
684 .filter(|v| !v.is_empty())
685 .unwrap_or_else(|| CLAUDE_ROUTINE_BETA.to_string());
686 let client = reqwest::blocking::Client::builder()
687 .timeout(std::time::Duration::from_secs(15))
688 .build()
689 .map_err(|err| WakeError::Transport(err.without_url().to_string()))?;
690 let response = client
691 .post(parsed)
692 .bearer_auth(token)
693 .header("anthropic-version", "2023-06-01")
694 .header("anthropic-beta", beta)
695 .json(&serde_json::json!({"text": wake_prompt(session, letter)}))
696 .send()
697 .map_err(|err| WakeError::Transport(err.without_url().to_string()))?;
698 if response.status().is_success() {
699 Ok(WakeOutcome::Delivered)
700 } else {
701 Err(WakeError::Transport(format!(
702 "routine fire status {}",
703 response.status().as_u16()
704 )))
705 }
706 }
707}
708
709fn is_loopback_http(url: &reqwest::Url) -> bool {
710 url.scheme() == "http"
711 && matches!(
712 url.host_str(),
713 Some("127.0.0.1" | "localhost" | "[::1]" | "::1")
714 )
715}
716
717pub struct CodexCloudAdapter;
722
723pub struct NoInboundAdapter {
725 pub provider: ProviderKind,
727}
728
729macro_rules! refuse_adapter {
730 ($ty:ty, $kind:expr, $err:ident) => {
731 impl WakeAdapter for $ty {
732 fn kind(&self) -> SessionKind {
733 $kind(self)
734 }
735
736 fn probe(&self, _session: &ProviderSession) -> Result<(), WakeError> {
737 Err(WakeError::$err(self.kind()))
738 }
739
740 fn wake(
741 &mut self,
742 _session: &ProviderSession,
743 _letter: &WakeLetter<'_>,
744 ) -> Result<WakeOutcome, WakeError> {
745 Err(WakeError::$err(self.kind()))
746 }
747 }
748 };
749}
750
751refuse_adapter!(
752 CodexCloudAdapter,
753 |_: &CodexCloudAdapter| SessionKind::web(ProviderKind::Codex),
754 NotImplemented
755);
756refuse_adapter!(
757 NoInboundAdapter,
758 |adapter: &NoInboundAdapter| SessionKind::web(adapter.provider),
759 NoInboundTrigger
760);
761
762#[cfg_attr(not(feature = "wake-grok"), allow(dead_code))]
763pub(crate) fn session_cwd(session: &ProviderSession) -> Result<String, WakeError> {
764 session
765 .cwd
766 .as_deref()
767 .map(Path::to_path_buf)
768 .or_else(|| std::env::current_dir().ok())
769 .map(|path| path.display().to_string())
770 .filter(|cwd| !cwd.is_empty())
771 .ok_or_else(|| WakeError::Unavailable("session cwd is empty".into()))
772}
773
774#[cfg(test)]
775pub(crate) mod tests {
776 use super::*;
777
778 pub(crate) fn session(kind: SessionKind) -> ProviderSession {
779 ProviderSession {
780 kind,
781 session_id: "s-1".into(),
782 nick: "alice".into(),
783 cwd: Some(PathBuf::from("/tmp")),
784 headless: false,
785 }
786 }
787
788 pub(crate) fn letter<'a>(body: &'a str) -> WakeLetter<'a> {
789 WakeLetter {
790 body,
791 from_nick: "carol",
792 event_id: "$ev1",
793 room: None,
794 }
795 }
796
797 #[test]
798 fn web_grok_uses_the_routine_only_when_one_is_configured() {
799 let kind = SessionKind::web(ProviderKind::Grok);
800 let none = adapter_for(kind, &AdapterConfig::default());
801 assert!(none.probe(&session(kind)).is_err());
802 let cfg = AdapterConfig { routine_url: Some("http://127.0.0.1:9/hook".into()), ..Default::default() };
803 let routine = adapter_for(kind, &cfg);
804 assert!(routine.probe(&session(kind)).is_ok());
805 }
806
807 #[test]
808 fn ids_round_trip() {
809 for kind in ProviderKind::ALL {
810 assert_eq!(ProviderKind::parse(kind.id()), Some(kind));
811 }
812 assert_eq!(
813 ProviderKind::parse("Claude-Code"),
814 Some(ProviderKind::ClaudeCode)
815 );
816 assert_eq!(ProviderKind::parse("gemini"), None);
817 assert!(ProviderKind::CORE.iter().all(|kind| !kind.is_optional()));
818 assert!(ProviderKind::Cursor.is_optional());
819 assert_eq!(
820 SessionKind::web(ProviderKind::Codex).to_string(),
821 "codex/web"
822 );
823 }
824
825 #[test]
826 fn every_kind_gets_an_adapter_of_that_kind() {
827 let config = AdapterConfig::default();
828 for provider in ProviderKind::ALL {
829 for kind in [SessionKind::local(provider), SessionKind::web(provider)] {
830 assert_eq!(adapter_for(kind, &config).kind(), kind);
831 }
832 }
833 }
834
835 #[test]
836 fn stubs_and_unconfigured_adapters_refuse_without_side_effects() {
837 let config = AdapterConfig::default();
838 let cases = [
839 (SessionKind::local(ProviderKind::Cursor), "unavailable"),
840 (SessionKind::web(ProviderKind::ClaudeCode), "unavailable"),
841 (SessionKind::web(ProviderKind::Codex), "not implemented"),
842 (SessionKind::web(ProviderKind::KimiCode), "no inbound"),
843 (SessionKind::web(ProviderKind::Grok), "no inbound"),
844 (SessionKind::web(ProviderKind::Cursor), "unavailable"),
845 (SessionKind::local(ProviderKind::Grok), "unavailable"),
846 (SessionKind::local(ProviderKind::Codex), "unavailable"),
847 (SessionKind::local(ProviderKind::KimiCode), "unavailable"),
848 (SessionKind::local(ProviderKind::ClaudeCode), "unavailable"),
849 ];
850 for (kind, expect) in cases {
851 let mut adapter = adapter_for(kind, &config);
852 let s = session(kind);
853 let err = adapter.wake(&s, &letter("x")).unwrap_err().to_string();
854 assert!(err.contains(expect), "{kind}: {err}");
855 }
856 }
857
858 #[test]
859 fn prompt_carries_reply_command_and_body() {
860 let text = wake_prompt(
861 &session(SessionKind::local(ProviderKind::Codex)),
862 &letter("ping"),
863 );
864 assert!(text.contains("reply-command: m4a-send --as alice --to carol"));
865 assert!(text.ends_with("---\nping"));
866 assert!(!text.contains("Answer every") && !text.contains("принято"), "no forced instruction");
867 }
868
869 #[test]
870 fn config_debug_hides_values() {
871 let config = AdapterConfig {
872 kimi_bearer: Some("k-secret".into()),
873 routine_url: Some("https://example.invalid/hook".into()),
874 ..AdapterConfig::default()
875 };
876 let shown = format!("{config:?}");
877 assert!(!shown.contains("secret") && !shown.contains("example"));
878 }
879}
880
881#[cfg(test)]
882mod g4a_alignment_tests {
883 use super::ProviderKind;
884
885 #[test]
888 fn names_hatchery_node_sends_all_parse() {
889 for (sent, kind) in [
890 ("claude-code", ProviderKind::ClaudeCode),
891 ("codex", ProviderKind::Codex),
892 ("grok", ProviderKind::Grok),
893 ("kimi-code", ProviderKind::KimiCode),
894 ("claude", ProviderKind::ClaudeCode),
895 ("kimi", ProviderKind::KimiCode),
896 ] {
897 assert_eq!(ProviderKind::parse(sent), Some(kind), "{sent}");
898 }
899 assert_eq!(ProviderKind::parse("custom"), None);
900 }
901}