pub mod chain;
pub mod hosted;
#[cfg(feature = "wake-claude")]
pub mod claude_channel;
#[cfg(not(feature = "wake-claude"))]
pub use off::claude_channel;
#[cfg(feature = "wake-codex")]
pub mod codex;
#[cfg(not(feature = "wake-codex"))]
pub use off::codex;
pub mod hook;
pub mod inbox;
#[cfg(feature = "wake-kimi")]
pub mod kimi;
pub mod kimi_acp;
#[cfg(not(feature = "wake-kimi"))]
pub use off::kimi;
pub mod registry;
#[cfg(feature = "wake-grok")]
pub mod grok_leader;
#[cfg(feature = "wake-routine")]
pub mod routine;
#[cfg(not(all(feature = "wake-claude", feature = "wake-codex", feature = "wake-kimi", feature = "wake-spawn")))]
mod off;
#[cfg(feature = "wake-spawn")]
pub mod spawn;
#[cfg(not(feature = "wake-spawn"))]
pub use off::spawn;
use std::fmt;
use std::path::{Path, PathBuf};
pub const LEADER_SOCK_ENV: &str = "M4A_LEADER_SOCK";
pub const SEND_COMMAND: &str = "m4a-send";
pub fn reply_hint(to: &str, from_nick: &str) -> String {
format!("{SEND_COMMAND} --as {to} --to {from_nick} '<your reply>'")
}
pub fn reply_hint_for(to: &str, from_nick: &str, room: Option<&str>) -> String {
match room.map(str::trim).filter(|r| r.starts_with('!') && !r.contains(char::is_whitespace) && !r.contains('\'')) {
Some(room) => format!("{ROOM_COMMAND} --as {to} send '{room}' '<your reply>'"),
None => reply_hint(to, from_nick),
}
}
pub const ROOM_COMMAND: &str = "m4a";
pub use chain::{plan_chain, ChainError, HostEnv, Mechanism, Tier, WakeChain, WebVendor};
pub use claude_channel::ClaudeChannelAdapter;
pub use codex::{CodexAppServerAdapter, CodexEndpoint};
pub use hook::{HookFlavor, InboxHookAdapter, InboxQueueAdapter};
pub use inbox::{InboxEntry, InboxLetter};
pub use kimi::KimiServerAdapter;
pub use registry::SessionRecord;
pub use spawn::ResumeSpawnAdapter;
pub const PROVIDER_ENV: &str = "M4A_PROVIDER";
pub const INBOX_DIR_ENV: &str = "M4A_INBOX_DIR";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ProviderKind {
Grok,
KimiCode,
ClaudeCode,
Codex,
Cursor,
}
impl ProviderKind {
pub const CORE: [ProviderKind; 4] = [
ProviderKind::Grok,
ProviderKind::KimiCode,
ProviderKind::ClaudeCode,
ProviderKind::Codex,
];
pub const ALL: [ProviderKind; 5] = [
ProviderKind::Grok,
ProviderKind::KimiCode,
ProviderKind::ClaudeCode,
ProviderKind::Codex,
ProviderKind::Cursor,
];
pub fn id(self) -> &'static str {
match self {
ProviderKind::Grok => "grok",
ProviderKind::KimiCode => "kimi",
ProviderKind::ClaudeCode => "claude",
ProviderKind::Codex => "codex",
ProviderKind::Cursor => "cursor",
}
}
pub fn is_optional(self) -> bool {
matches!(self, ProviderKind::Cursor)
}
pub fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"grok" => Some(ProviderKind::Grok),
"kimi" | "kimi-code" | "kimicode" => Some(ProviderKind::KimiCode),
"claude" | "claude-code" | "claudecode" => Some(ProviderKind::ClaudeCode),
"codex" => Some(ProviderKind::Codex),
"cursor" | "cursor-agent" => Some(ProviderKind::Cursor),
_ => None,
}
}
pub fn from_env() -> Result<Self, WakeError> {
match std::env::var(PROVIDER_ENV) {
Ok(value) if !value.trim().is_empty() => {
Self::parse(&value).ok_or(WakeError::UnknownProvider)
}
_ => Ok(ProviderKind::Grok),
}
}
}
impl fmt::Display for ProviderKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.id())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Surface {
Web,
Local,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct SessionKind {
pub provider: ProviderKind,
pub surface: Surface,
}
impl SessionKind {
pub fn local(provider: ProviderKind) -> Self {
Self {
provider,
surface: Surface::Local,
}
}
pub fn web(provider: ProviderKind) -> Self {
Self {
provider,
surface: Surface::Web,
}
}
}
impl fmt::Display for SessionKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let surface = match self.surface {
Surface::Web => "web",
Surface::Local => "local",
};
write!(f, "{}/{}", self.provider, surface)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderSession {
pub kind: SessionKind,
pub session_id: String,
pub nick: String,
pub cwd: Option<PathBuf>,
pub headless: bool,
}
#[derive(Debug, Clone, Copy)]
pub struct WakeLetter<'a> {
pub body: &'a str,
pub from_nick: &'a str,
pub event_id: &'a str,
pub room: Option<&'a str>,
pub reaction: Option<ReactionNote<'a>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ReactionNote<'a> {
pub key: &'a str,
pub target: &'a str,
}
pub fn react_hint_for(to: &str, room: Option<&str>, event: &str) -> Option<String> {
let room = room.map(str::trim).filter(|r| r.starts_with('!') && !r.contains(char::is_whitespace) && !r.contains('\''))?;
let event = Some(event.trim()).filter(|e| e.starts_with('$') && !e.contains(char::is_whitespace) && !e.contains('\''))?;
Some(format!("{ROOM_COMMAND} --as {to} react '{room}' '{event}' '<emoji>'"))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WakeOutcome {
Delivered,
Queued(PathBuf),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WakeError {
UnknownProvider,
NotImplemented(SessionKind),
NoInboundTrigger(SessionKind),
Unavailable(String),
Transport(String),
}
impl fmt::Display for WakeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
WakeError::UnknownProvider => write!(f, "unknown provider in {PROVIDER_ENV}"),
WakeError::NotImplemented(kind) => {
write!(f, "wake adapter for {kind} is not implemented")
}
WakeError::NoInboundTrigger(kind) => {
write!(
f,
"{kind} exposes no inbound trigger into a running session"
)
}
WakeError::Unavailable(why) => write!(f, "wake endpoint unavailable: {why}"),
WakeError::Transport(why) => write!(f, "wake failed: {why}"),
}
}
}
impl std::error::Error for WakeError {}
pub trait WakeAdapter: Send {
fn kind(&self) -> SessionKind;
fn probe(&self, session: &ProviderSession) -> Result<(), WakeError>;
fn wake(
&mut self,
session: &ProviderSession,
letter: &WakeLetter<'_>,
) -> Result<WakeOutcome, WakeError>;
}
pub fn wake_prompt(session: &ProviderSession, letter: &WakeLetter<'_>) -> String {
let mut head = format!("[mail4agent letter]\nkind: {}\nfrom: {from}\nto: {to}\n", if letter.reaction.is_some() { "reaction" } else { "message" }, from = letter.from_nick, to = session.nick);
if let Some(room) = letter.room.map(str::trim).filter(|r| !r.is_empty()) {
head.push_str(&format!("room: {room}\n"));
}
head.push_str(&format!("event: {}\n", letter.event_id));
match letter.reaction {
Some(r) => {
head.push_str(&format!("reaction: {}\ntarget-event: {}\npriority: low\nreply-needed: no\n", r.key, r.target));
if let Some(h) = react_hint_for(&session.nick, letter.room, r.target) {
head.push_str(&format!("react-command: {h}\n"));
}
}
None => {
head.push_str(&format!("reply-command: {}\n", reply_hint_for(&session.nick, letter.from_nick, letter.room)));
if let Some(room) = letter.room.map(str::trim).filter(|r| r.starts_with('!') && !r.contains('\'')) {
if letter.event_id.starts_with('$') && !letter.event_id.contains('\'') {
head.push_str(&format!("reply-to-command: {ROOM_COMMAND} --as {} send '{room}' --reply-to '{}' '<your reply>'\n", session.nick, letter.event_id));
}
}
if let Some(h) = react_hint_for(&session.nick, letter.room, letter.event_id) {
head.push_str(&format!("react-command: {h}\n"));
}
}
}
format!("{head}---\n{body}", body = letter.body)
}
#[derive(Default, Clone)]
pub struct AdapterConfig {
pub leader_sock: Option<PathBuf>,
pub inbox_dir: Option<PathBuf>,
pub codex: Option<CodexEndpoint>,
pub kimi_url: Option<String>,
pub kimi_bearer: Option<String>,
pub kimi_acp_bin: Option<String>,
pub routine_url: Option<String>,
pub routine_bearer: Option<String>,
pub claude_fire_url: Option<String>,
pub claude_fire_bearer: Option<String>,
pub codex_cloud_env: Option<String>,
pub spawn_program: Option<PathBuf>,
}
pub const CLAUDE_FIRE_URL_ENV: &str = "M4A_CLAUDE_ROUTINE_FIRE_URL";
pub const CLAUDE_FIRE_TOKEN_ENV: &str = "M4A_CLAUDE_ROUTINE_TOKEN";
impl AdapterConfig {
pub fn from_env(inbox_dir: Option<PathBuf>) -> Self {
let get = |key: &str| std::env::var(key).ok().filter(|value| !value.is_empty());
let mut kimi = KimiServerAdapter::from_env();
if !kimi.is_configured() {
if let Some(found) = kimi::discover_local_server(None) {
kimi = found;
}
}
let (kimi_url, kimi_bearer) = kimi.into_parts();
Self {
leader_sock: get(crate::LEADER_SOCK_ENV).map(PathBuf::from),
inbox_dir: get(INBOX_DIR_ENV).map(PathBuf::from).or(inbox_dir),
codex: CodexEndpoint::from_env(),
kimi_url,
kimi_bearer,
kimi_acp_bin: kimi_acp::KimiAcpConfig::from_env().map(|c| c.bin),
routine_url: None,
routine_bearer: None,
claude_fire_url: get(CLAUDE_FIRE_URL_ENV),
claude_fire_bearer: get(CLAUDE_FIRE_TOKEN_ENV),
codex_cloud_env: get(spawn::CODEX_CLOUD_ENV_ENV),
spawn_program: None,
}
}
}
impl fmt::Debug for AdapterConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("AdapterConfig")
.field("leader_sock", &self.leader_sock.is_some())
.field("inbox_dir", &self.inbox_dir.is_some())
.field("codex", &self.codex.is_some())
.field("kimi_url", &self.kimi_url.is_some())
.field("routine_url", &self.routine_url.is_some())
.field("claude_fire_url", &self.claude_fire_url.is_some())
.field("codex_cloud_env", &self.codex_cloud_env.is_some())
.finish()
}
}
pub fn adapter_for(kind: SessionKind, config: &AdapterConfig) -> Box<dyn WakeAdapter> {
match (kind.surface, kind.provider) {
(Surface::Local, ProviderKind::Grok) => Box::new(GrokLeaderAdapter {
leader_sock: config.leader_sock.clone(),
}),
(Surface::Local, ProviderKind::Codex) => {
Box::new(CodexAppServerAdapter::new(config.codex.clone()))
}
(Surface::Local, ProviderKind::KimiCode) => {
if let Some(bin) = config.kimi_acp_bin.clone() {
Box::new(kimi_acp::KimiAcpAdapter::new(Some(kimi_acp::KimiAcpConfig { bin })))
} else {
Box::new(KimiServerAdapter::new(
config.kimi_url.clone(),
config.kimi_bearer.clone(),
))
}
},
(Surface::Local, ProviderKind::ClaudeCode) => {
Box::new(ClaudeChannelAdapter::new(config.inbox_dir.clone()))
}
(Surface::Local, ProviderKind::Cursor) => Box::new(CursorAgentAdapter {
inbox_dir: config.inbox_dir.clone(),
}),
(Surface::Web, ProviderKind::Cursor) => Box::new(RoutineWebhookAdapter {
provider: ProviderKind::Cursor,
url: config.routine_url.clone(),
bearer: config.routine_bearer.clone(),
}),
(Surface::Web, ProviderKind::ClaudeCode) => Box::new(ClaudeRoutineFireAdapter {
url: config.claude_fire_url.clone(),
bearer: config.claude_fire_bearer.clone(),
}),
(Surface::Web, ProviderKind::Codex) => Box::new(CodexCloudAdapter),
(Surface::Web, provider @ (ProviderKind::KimiCode | ProviderKind::Grok)) => {
if config.routine_url.as_deref().is_some_and(|u| !u.is_empty()) {
Box::new(RoutineWebhookAdapter {
provider,
url: config.routine_url.clone(),
bearer: config.routine_bearer.clone(),
})
} else {
Box::new(NoInboundAdapter { provider })
}
}
}
}
pub struct GrokLeaderAdapter {
pub leader_sock: Option<PathBuf>,
}
impl WakeAdapter for GrokLeaderAdapter {
fn kind(&self) -> SessionKind {
SessionKind::local(ProviderKind::Grok)
}
fn probe(&self, _session: &ProviderSession) -> Result<(), WakeError> {
match self.leader_sock.as_deref() {
Some(path) if path.exists() => Ok(()),
Some(_) => Err(WakeError::Unavailable("leader socket missing".into())),
None => Err(WakeError::Unavailable("leader socket unset".into())),
}
}
fn wake(
&mut self,
session: &ProviderSession,
letter: &WakeLetter<'_>,
) -> Result<WakeOutcome, WakeError> {
let _ = (&session, &letter);
#[cfg(not(feature = "wake-grok"))]
return Err(WakeError::Unavailable("this build was made without feature wake-grok".into()));
#[cfg(feature = "wake-grok")]
self.probe(session)?;
#[cfg(feature = "wake-grok")]
let Some(sock) = self.leader_sock.as_deref() else {
return Err(WakeError::Unavailable("leader socket unset".into()));
};
#[cfg(feature = "wake-grok")]
let cwd = session_cwd(session)?;
#[cfg(feature = "wake-grok")]
{
grok_leader::wake_decrypted_room_blocking(sock, &session.session_id, &cwd, &wake_prompt(session, letter))
.map(|()| WakeOutcome::Delivered)
.map_err(|e| WakeError::Transport(e.to_string()))
}
}
}
pub struct CursorAgentAdapter {
pub inbox_dir: Option<PathBuf>,
}
impl WakeAdapter for CursorAgentAdapter {
fn kind(&self) -> SessionKind {
SessionKind::local(ProviderKind::Cursor)
}
fn probe(&self, session: &ProviderSession) -> Result<(), WakeError> {
InboxHookAdapter::new(self.kind(), HookFlavor::CursorStop, self.inbox_dir.clone())
.probe(session)
}
fn wake(
&mut self,
session: &ProviderSession,
letter: &WakeLetter<'_>,
) -> Result<WakeOutcome, WakeError> {
if !cfg!(feature = "wake-cursor") {
return Err(WakeError::Unavailable("this build was made without feature wake-cursor".into()));
}
InboxHookAdapter::new(self.kind(), HookFlavor::CursorStop, self.inbox_dir.clone())
.wake(session, letter)
}
}
pub struct RoutineWebhookAdapter {
pub provider: ProviderKind,
pub url: Option<String>,
pub bearer: Option<String>,
}
impl WakeAdapter for RoutineWebhookAdapter {
fn kind(&self) -> SessionKind {
SessionKind::web(self.provider)
}
fn probe(&self, _session: &ProviderSession) -> Result<(), WakeError> {
match self.url.as_deref() {
Some(url) if !url.is_empty() => Ok(()),
_ => Err(WakeError::Unavailable("routine url unset".into())),
}
}
fn wake(
&mut self,
session: &ProviderSession,
letter: &WakeLetter<'_>,
) -> Result<WakeOutcome, WakeError> {
if !cfg!(feature = "wake-routine") {
return Err(WakeError::Unavailable("this build was made without feature wake-routine".into()));
}
self.probe(session)?;
let url = self.url.as_deref().unwrap_or_default();
let reply = if letter.reaction.is_some() { String::new() } else { reply_hint_for(&session.nick, letter.from_nick, letter.room) };
let react_target = letter.reaction.map(|r| r.target).unwrap_or(letter.event_id);
let react = react_hint_for(&session.nick, letter.room, react_target).unwrap_or_default();
let mut body = serde_json::Map::new();
body.insert("kind".into(), serde_json::Value::String(if letter.reaction.is_some() { "reaction" } else { "message" }.into()));
body.insert("reply_needed".into(), serde_json::Value::Bool(letter.reaction.is_none()));
if let Some(r) = letter.reaction {
body.insert("priority".into(), serde_json::Value::String("low".into()));
body.insert("reaction".into(), serde_json::Value::String(r.key.into()));
body.insert("target_event_id".into(), serde_json::Value::String(r.target.into()));
}
for (k, v) in [
("body", Some(letter.body)),
("from", Some(letter.from_nick)),
("event_id", Some(letter.event_id)),
("room", letter.room),
("from_nick", Some(letter.from_nick)),
("to", Some(session.nick.as_str())),
("reply", Some(reply.as_str())),
("react", Some(react.as_str())),
] {
if let Some(v) = v.map(str::trim).filter(|v| !v.is_empty() || k == "body") {
body.insert(k.into(), serde_json::Value::String(v.into()));
}
}
#[cfg(not(feature = "wake-routine"))]
{
let _ = (url, body);
return Err(WakeError::Unavailable("this build was made without feature wake-routine".into()));
}
#[cfg(feature = "wake-routine")]
routine::post_routine_json(url, &serde_json::Value::Object(body), self.bearer.as_deref())
.map(|()| WakeOutcome::Delivered)
.map_err(|e| WakeError::Transport(e.to_string()))
}
}
pub struct ClaudeRoutineFireAdapter {
pub url: Option<String>,
pub bearer: Option<String>,
}
pub const CLAUDE_ROUTINE_BETA: &str = "experimental-cc-routine-2026-04-01";
impl WakeAdapter for ClaudeRoutineFireAdapter {
fn kind(&self) -> SessionKind {
SessionKind::web(ProviderKind::ClaudeCode)
}
fn probe(&self, session: &ProviderSession) -> Result<(), WakeError> {
if self.url.as_deref().is_none_or(str::is_empty) {
return Err(WakeError::Unavailable(
"claude routine fire url unset".into(),
));
}
if self.bearer.as_deref().is_none_or(str::is_empty) {
return Err(WakeError::Unavailable("claude routine token unset".into()));
}
if !session.headless {
return Err(WakeError::Unavailable(
"session is open; routine fire would start a new session".into(),
));
}
Ok(())
}
fn wake(
&mut self,
session: &ProviderSession,
letter: &WakeLetter<'_>,
) -> Result<WakeOutcome, WakeError> {
if !cfg!(feature = "wake-claude") {
return Err(WakeError::Unavailable("this build was made without feature wake-claude".into()));
}
self.probe(session)?;
let url = self.url.as_deref().unwrap_or_default();
let token = self.bearer.as_deref().unwrap_or_default();
let parsed = reqwest::Url::parse(url)
.ok()
.filter(|u| u.scheme() == "https" || is_loopback_http(u))
.ok_or_else(|| WakeError::Unavailable("claude routine fire url invalid".into()))?;
let beta = std::env::var("M4A_CLAUDE_ROUTINE_BETA")
.ok()
.filter(|v| !v.is_empty())
.unwrap_or_else(|| CLAUDE_ROUTINE_BETA.to_string());
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|err| WakeError::Transport(err.without_url().to_string()))?;
let response = client
.post(parsed)
.bearer_auth(token)
.header("anthropic-version", "2023-06-01")
.header("anthropic-beta", beta)
.json(&serde_json::json!({"text": wake_prompt(session, letter)}))
.send()
.map_err(|err| WakeError::Transport(err.without_url().to_string()))?;
if response.status().is_success() {
Ok(WakeOutcome::Delivered)
} else {
Err(WakeError::Transport(format!(
"routine fire status {}",
response.status().as_u16()
)))
}
}
}
fn is_loopback_http(url: &reqwest::Url) -> bool {
url.scheme() == "http"
&& matches!(
url.host_str(),
Some("127.0.0.1" | "localhost" | "[::1]" | "::1")
)
}
pub struct CodexCloudAdapter;
pub struct NoInboundAdapter {
pub provider: ProviderKind,
}
macro_rules! refuse_adapter {
($ty:ty, $kind:expr, $err:ident) => {
impl WakeAdapter for $ty {
fn kind(&self) -> SessionKind {
$kind(self)
}
fn probe(&self, _session: &ProviderSession) -> Result<(), WakeError> {
Err(WakeError::$err(self.kind()))
}
fn wake(
&mut self,
_session: &ProviderSession,
_letter: &WakeLetter<'_>,
) -> Result<WakeOutcome, WakeError> {
Err(WakeError::$err(self.kind()))
}
}
};
}
refuse_adapter!(
CodexCloudAdapter,
|_: &CodexCloudAdapter| SessionKind::web(ProviderKind::Codex),
NotImplemented
);
refuse_adapter!(
NoInboundAdapter,
|adapter: &NoInboundAdapter| SessionKind::web(adapter.provider),
NoInboundTrigger
);
#[cfg_attr(not(feature = "wake-grok"), allow(dead_code))]
pub(crate) fn session_cwd(session: &ProviderSession) -> Result<String, WakeError> {
session
.cwd
.as_deref()
.map(Path::to_path_buf)
.or_else(|| std::env::current_dir().ok())
.map(|path| path.display().to_string())
.filter(|cwd| !cwd.is_empty())
.ok_or_else(|| WakeError::Unavailable("session cwd is empty".into()))
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
pub(crate) fn session(kind: SessionKind) -> ProviderSession {
ProviderSession {
kind,
session_id: "s-1".into(),
nick: "alice".into(),
cwd: Some(PathBuf::from("/tmp")),
headless: false,
}
}
pub(crate) fn letter<'a>(body: &'a str) -> WakeLetter<'a> {
WakeLetter {
body,
from_nick: "carol",
event_id: "$ev1",
room: None,
reaction: None,
}
}
#[test]
fn reaction_letters_are_low_priority_with_no_reply_command() {
let session = ProviderSession { kind: SessionKind::web(ProviderKind::Grok), session_id: "s".into(), nick: "hatcheryweb".into(), cwd: None, headless: false };
let l = WakeLetter { body: "kimi reacted 👍 to your message: \"hi\"", from_nick: "kimi", event_id: "$r1", room: Some("!room:x"), reaction: Some(ReactionNote { key: "👍", target: "$m1" }) };
let p = wake_prompt(&session, &l);
assert!(p.contains("kind: reaction\n") && p.contains("reaction: 👍\n") && p.contains("target-event: $m1\n") && p.contains("priority: low\n") && p.contains("reply-needed: no\n"));
assert!(!p.contains("reply-command:"));
assert!(p.contains("react-command: m4a --as hatcheryweb react '!room:x' '$m1' '<emoji>'"));
let m = WakeLetter { reaction: None, event_id: "$m2", ..l };
let p = wake_prompt(&session, &m);
assert!(p.contains("kind: message\n") && p.contains("event: $m2\n") && p.contains("reply-command: m4a --as hatcheryweb send '!room:x'"));
assert!(p.contains("reply-to-command: m4a --as hatcheryweb send '!room:x' --reply-to '$m2' '<your reply>'"));
}
#[test]
fn web_grok_uses_the_routine_only_when_one_is_configured() {
let kind = SessionKind::web(ProviderKind::Grok);
let none = adapter_for(kind, &AdapterConfig::default());
assert!(none.probe(&session(kind)).is_err());
let cfg = AdapterConfig { routine_url: Some("http://127.0.0.1:9/hook".into()), ..Default::default() };
let routine = adapter_for(kind, &cfg);
assert!(routine.probe(&session(kind)).is_ok());
}
#[test]
fn ids_round_trip() {
for kind in ProviderKind::ALL {
assert_eq!(ProviderKind::parse(kind.id()), Some(kind));
}
assert_eq!(
ProviderKind::parse("Claude-Code"),
Some(ProviderKind::ClaudeCode)
);
assert_eq!(ProviderKind::parse("gemini"), None);
assert!(ProviderKind::CORE.iter().all(|kind| !kind.is_optional()));
assert!(ProviderKind::Cursor.is_optional());
assert_eq!(
SessionKind::web(ProviderKind::Codex).to_string(),
"codex/web"
);
}
#[test]
fn every_kind_gets_an_adapter_of_that_kind() {
let config = AdapterConfig::default();
for provider in ProviderKind::ALL {
for kind in [SessionKind::local(provider), SessionKind::web(provider)] {
assert_eq!(adapter_for(kind, &config).kind(), kind);
}
}
}
#[test]
fn stubs_and_unconfigured_adapters_refuse_without_side_effects() {
let config = AdapterConfig::default();
let cases = [
(SessionKind::local(ProviderKind::Cursor), "unavailable"),
(SessionKind::web(ProviderKind::ClaudeCode), "unavailable"),
(SessionKind::web(ProviderKind::Codex), "not implemented"),
(SessionKind::web(ProviderKind::KimiCode), "no inbound"),
(SessionKind::web(ProviderKind::Grok), "no inbound"),
(SessionKind::web(ProviderKind::Cursor), "unavailable"),
(SessionKind::local(ProviderKind::Grok), "unavailable"),
(SessionKind::local(ProviderKind::Codex), "unavailable"),
(SessionKind::local(ProviderKind::KimiCode), "unavailable"),
(SessionKind::local(ProviderKind::ClaudeCode), "unavailable"),
];
for (kind, expect) in cases {
let mut adapter = adapter_for(kind, &config);
let s = session(kind);
let err = adapter.wake(&s, &letter("x")).unwrap_err().to_string();
assert!(err.contains(expect), "{kind}: {err}");
}
}
#[test]
fn room_letters_are_answered_in_the_room() {
assert_eq!(reply_hint_for("alice", "carol", Some("!r1:example.org")), "m4a --as alice send '!r1:example.org' '<your reply>'");
assert_eq!(reply_hint_for("alice", "carol", None), reply_hint("alice", "carol"));
assert_eq!(reply_hint_for("alice", "carol", Some("bad room")), reply_hint("alice", "carol"));
}
#[test]
fn prompt_carries_reply_command_and_body() {
let text = wake_prompt(
&session(SessionKind::local(ProviderKind::Codex)),
&letter("ping"),
);
assert!(text.contains("reply-command: m4a-send --as alice --to carol"));
assert!(text.ends_with("---\nping"));
assert!(!text.contains("Answer every") && !text.contains("принято"), "no forced instruction");
}
#[test]
fn config_debug_hides_values() {
let config = AdapterConfig {
kimi_bearer: Some("k-secret".into()),
routine_url: Some("https://example.invalid/hook".into()),
..AdapterConfig::default()
};
let shown = format!("{config:?}");
assert!(!shown.contains("secret") && !shown.contains("example"));
}
}
#[cfg(test)]
mod g4a_alignment_tests {
use super::ProviderKind;
#[test]
fn names_hatchery_node_sends_all_parse() {
for (sent, kind) in [
("claude-code", ProviderKind::ClaudeCode),
("codex", ProviderKind::Codex),
("grok", ProviderKind::Grok),
("kimi-code", ProviderKind::KimiCode),
("claude", ProviderKind::ClaudeCode),
("kimi", ProviderKind::KimiCode),
] {
assert_eq!(ProviderKind::parse(sent), Some(kind), "{sent}");
}
assert_eq!(ProviderKind::parse("custom"), None);
}
}