Skip to main content

gate4agent_wake/
chain.rs

1//! Ordered wake chains and host/session detection.
2//!
3//! A [`WakeChain`] tries mechanisms in order until one delivers or queues:
4//! in-session trigger first (a turn in the client that already has the
5//! session open), then hooks inside that session, then a durable inbox
6//! queue, and a new-process resume only for a headless session.
7//! [`plan_chain`] builds the chain from where this client runs
8//! ([`HostEnv`]) and what the session is ([`ProviderSession`]).
9
10use std::fmt;
11use std::path::{Path, PathBuf};
12
13use super::claude_channel::ClaudeChannelAdapter;
14use super::codex::CodexAppServerAdapter;
15use super::hook::{HookFlavor, InboxHookAdapter, InboxQueueAdapter};
16use super::kimi::KimiServerAdapter;
17use super::kimi_acp::{KimiAcpAdapter, KimiAcpConfig};
18use super::spawn::ResumeSpawnAdapter;
19use super::{
20    AdapterConfig, ClaudeRoutineFireAdapter, GrokLeaderAdapter, NoInboundAdapter, ProviderKind,
21    ProviderSession, RoutineWebhookAdapter, SessionKind, Surface, WakeAdapter, WakeError,
22    WakeLetter, WakeOutcome,
23};
24
25/// Explicit surface override: `web` or `local`.
26pub const SURFACE_ENV: &str = "M4A_SURFACE";
27/// Explicit web vendor override: `grok-bot`, `claude-web`, `codex-cloud`, `cursor-cloud`.
28pub const WEB_VENDOR_ENV: &str = "M4A_WEB_VENDOR";
29/// Marks the session as headless (no client holds it open): `1`.
30pub const HEADLESS_ENV: &str = "M4A_HEADLESS";
31
32/// Where a mechanism sits in the order.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum Tier {
35    /// Starts a turn in the client that has the session open, now.
36    InSession,
37    /// A hook inside the open session picks the letter up.
38    Hook,
39    /// Durable inbox; delivered by the next hook run / channel start.
40    Queue,
41    /// New process resuming the session. Headless sessions only.
42    Spawn,
43}
44
45/// One row of the per-kind table.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub struct Mechanism {
48    /// Stable id (also the chain link id).
49    pub id: &'static str,
50    /// Tier.
51    pub tier: Tier,
52    /// Verified live on a real CLI on 2026-10-06.
53    pub verified: bool,
54}
55
56const fn m(id: &'static str, tier: Tier, verified: bool) -> Mechanism {
57    Mechanism { id, tier, verified }
58}
59
60/// Vendor box this client runs on, when it is not the user's machine.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum WebVendor {
63    /// Grok Bot / Cursor-style agent box with a webhook routine.
64    GrokBot,
65    /// Claude Code on the web (cloud container).
66    ClaudeWeb,
67    /// Codex cloud task container.
68    CodexCloud,
69    /// Cursor cloud agent.
70    CursorCloud,
71}
72
73impl WebVendor {
74    /// Parses [`WEB_VENDOR_ENV`].
75    pub fn parse(value: &str) -> Option<Self> {
76        match value.trim().to_ascii_lowercase().replace('_', "-").as_str() {
77            "grok-bot" | "grokbot" => Some(Self::GrokBot),
78            "claude-web" | "claude" => Some(Self::ClaudeWeb),
79            "codex-cloud" | "codex" => Some(Self::CodexCloud),
80            "cursor-cloud" | "cursor" => Some(Self::CursorCloud),
81            _ => None,
82        }
83    }
84}
85
86/// Where this client runs.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub struct HostEnv {
89    /// Web vendor host or the user's machine.
90    pub surface: Surface,
91    /// Which vendor box, when web.
92    pub vendor: Option<WebVendor>,
93    /// `std::env::consts::OS` (`linux`, `macos`, `windows`).
94    pub os: &'static str,
95}
96
97impl HostEnv {
98    /// Detects from the process environment and the filesystem.
99    pub fn detect() -> Self {
100        Self::detect_from(|key| std::env::var(key).ok(), |path| path.exists())
101    }
102
103    /// Detection with injected lookups (tests).
104    ///
105    /// Order: [`SURFACE_ENV`] / [`WEB_VENDOR_ENV`] overrides, then vendor
106    /// markers: `CLAUDE_CODE_REMOTE=true` (Claude web container),
107    /// `<home>/agent-data` (Grok Bot box). Anything else is local.
108    pub fn detect_from(
109        get: impl Fn(&str) -> Option<String>,
110        exists: impl Fn(&Path) -> bool,
111    ) -> Self {
112        let os = std::env::consts::OS;
113        let set = |key: &str| get(key).filter(|value| !value.trim().is_empty());
114        let vendor = set(WEB_VENDOR_ENV)
115            .and_then(|value| WebVendor::parse(&value))
116            .or_else(|| {
117                set("CLAUDE_CODE_REMOTE")
118                    .filter(|value| value.eq_ignore_ascii_case("true") || value == "1")
119                    .map(|_| WebVendor::ClaudeWeb)
120            })
121            .or_else(|| {
122                let home = get("HOME").filter(|h| !h.is_empty());
123                let marker = home.map(|h| Path::new(&h).join("agent-data")).unwrap_or_else(|| Path::new("agent-data").to_path_buf());
124                exists(&marker).then_some(WebVendor::GrokBot)
125            });
126        let surface = match set(SURFACE_ENV)
127            .as_deref()
128            .map(str::to_ascii_lowercase)
129            .as_deref()
130        {
131            Some("local") => {
132                return Self {
133                    surface: Surface::Local,
134                    vendor: None,
135                    os,
136                }
137            }
138            Some("web") => Surface::Web,
139            _ if vendor.is_some() => Surface::Web,
140            _ => Surface::Local,
141        };
142        Self {
143            surface,
144            vendor,
145            os,
146        }
147    }
148}
149
150/// Provider of the session this process runs inside, from env markers
151/// the CLIs export to their child processes: `M4A_PROVIDER` first, then
152/// `CLAUDECODE` (Claude Code), `CODEX_THREAD_ID` (Codex), `CURSOR_AGENT`
153/// (Cursor CLI). Kimi and Grok export no session marker: set
154/// `M4A_PROVIDER`.
155pub fn detect_provider(get: impl Fn(&str) -> Option<String>) -> Option<ProviderKind> {
156    let set = |key: &str| get(key).filter(|value| !value.trim().is_empty());
157    if let Some(value) = set(super::PROVIDER_ENV) {
158        return ProviderKind::parse(&value);
159    }
160    if set("CLAUDECODE").is_some() || set("CLAUDE_CODE_REMOTE").is_some() {
161        return Some(ProviderKind::ClaudeCode);
162    }
163    if set("CODEX_THREAD_ID").is_some() {
164        return Some(ProviderKind::Codex);
165    }
166    if set("CURSOR_AGENT").is_some() {
167        return Some(ProviderKind::Cursor);
168    }
169    None
170}
171
172/// Vendor session id from env, where the CLI exports one.
173pub fn detect_session_id(
174    provider: ProviderKind,
175    get: impl Fn(&str) -> Option<String>,
176) -> Option<String> {
177    let keys: &[&str] = match provider {
178        ProviderKind::ClaudeCode => &[
179            "CLAUDE_CODE_SESSION_ID",
180            "CLAUDE_SESSION_ID",
181            "CLAUDE_CODE_REMOTE_SESSION_ID",
182        ],
183        ProviderKind::Codex => &["CODEX_THREAD_ID", "CODEX_SESSION_ID"],
184        _ => &[],
185    };
186    keys.iter()
187        .find_map(|key| get(key).filter(|value| !value.trim().is_empty()))
188}
189
190/// The ordered mechanism table for `kind` on `vendor` (web) hosts.
191/// [`plan_chain`] follows exactly this order.
192pub fn mechanisms(kind: SessionKind, vendor: Option<WebVendor>) -> Vec<Mechanism> {
193    use Tier::*;
194    match (kind.surface, kind.provider) {
195        (Surface::Local, ProviderKind::Grok) => vec![
196            m("grok-leader-acp", InSession, true),
197            m("grok-stop-hook", Hook, false),
198            m("inbox-queue", Queue, true),
199            m("grok-resume-spawn", Spawn, false),
200        ],
201        (Surface::Local, ProviderKind::Codex) => vec![
202            m("codex-app-server-turn", InSession, true),
203            m("codex-stop-hook", Hook, false),
204            m("inbox-queue", Queue, true),
205            m("codex-exec-resume-spawn", Spawn, false),
206        ],
207        (Surface::Local, ProviderKind::KimiCode) => vec![
208            m("kimi-acp-host", InSession, true),
209            m("kimi-server-prompt", InSession, true),
210            m("kimi-stop-hook", Hook, false),
211            m("inbox-queue", Queue, true),
212            m("kimi-resume-spawn", Spawn, false),
213        ],
214        (Surface::Local, ProviderKind::ClaudeCode) => vec![
215            m("claude-channel", InSession, false),
216            m("claude-async-rewake", InSession, false),
217            m("claude-stop-hook", Hook, false),
218            m("inbox-queue", Queue, true),
219            m("claude-resume-spawn", Spawn, false),
220        ],
221        (Surface::Local, ProviderKind::Cursor) => vec![
222            m("cursor-stop-followup", Hook, false),
223            m("inbox-queue", Queue, true),
224            m("cursor-resume-spawn", Spawn, false),
225        ],
226        (Surface::Web, provider) => {
227            let mut rows = Vec::new();
228            if matches!(vendor, Some(WebVendor::GrokBot | WebVendor::CursorCloud))
229                || provider == ProviderKind::Cursor
230            {
231                rows.push(m(
232                    "routine-webhook",
233                    InSession,
234                    vendor == Some(WebVendor::GrokBot),
235                ));
236            }
237            match provider {
238                ProviderKind::ClaudeCode => {
239                    rows.push(m("claude-async-rewake", InSession, false));
240                    rows.push(m("claude-stop-hook", Hook, false));
241                    rows.push(m("inbox-queue", Queue, true));
242                    rows.push(m("claude-routine-fire", Spawn, false));
243                }
244                ProviderKind::Codex => {
245                    rows.push(m("codex-stop-hook", Hook, false));
246                    rows.push(m("inbox-queue", Queue, true));
247                    rows.push(m("codex-cloud-exec", Spawn, false));
248                }
249                ProviderKind::Cursor => {
250                    rows.push(m("cursor-stop-followup", Hook, false));
251                    rows.push(m("inbox-queue", Queue, true));
252                }
253                ProviderKind::KimiCode | ProviderKind::Grok => {
254                    if rows.is_empty() {
255                        rows.push(m("no-inbound", Spawn, true));
256                    }
257                }
258            }
259            rows
260        }
261    }
262}
263
264/// Ordered adapters with ids.
265#[derive(Default)]
266pub struct WakeChain {
267    links: Vec<(&'static str, Box<dyn WakeAdapter>)>,
268}
269
270/// Why every link refused. Ids and errors only (no body, no secret).
271#[derive(Debug, Clone, PartialEq, Eq)]
272pub struct ChainError(pub Vec<(&'static str, WakeError)>);
273
274impl fmt::Display for ChainError {
275    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
276        if self.0.is_empty() {
277            return write!(f, "wake chain is empty");
278        }
279        let parts: Vec<String> = self
280            .0
281            .iter()
282            .map(|(id, err)| format!("{id}: {err}"))
283            .collect();
284        write!(f, "{}", parts.join("; "))
285    }
286}
287
288impl std::error::Error for ChainError {}
289
290impl fmt::Debug for WakeChain {
291    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
292        f.debug_list().entries(self.ids()).finish()
293    }
294}
295
296impl WakeChain {
297    /// Empty chain.
298    pub fn new() -> Self {
299        Self::default()
300    }
301
302    /// Appends a link.
303    pub fn push(&mut self, id: &'static str, adapter: Box<dyn WakeAdapter>) {
304        self.links.push((id, adapter));
305    }
306
307    /// Puts a link in front (an embedder's own first stop, e.g. m4a's letter hook).
308    pub fn prepend(&mut self, id: &'static str, adapter: Box<dyn WakeAdapter>) {
309        self.links.insert(0, (id, adapter));
310    }
311
312    /// Link ids in order.
313    pub fn ids(&self) -> Vec<&'static str> {
314        self.links.iter().map(|(id, _)| *id).collect()
315    }
316
317    /// Whether the chain has no link.
318    pub fn is_empty(&self) -> bool {
319        self.links.is_empty()
320    }
321
322    /// First link whose probe passes, without waking.
323    pub fn best(&self, session: &ProviderSession) -> Option<&'static str> {
324        self.links
325            .iter()
326            .find(|(_, adapter)| adapter.probe(session).is_ok())
327            .map(|(id, _)| *id)
328    }
329
330    /// Tries each link in order. The first `Delivered` or `Queued` wins.
331    pub fn wake(
332        &mut self,
333        session: &ProviderSession,
334        letter: &WakeLetter<'_>,
335    ) -> Result<(&'static str, WakeOutcome), ChainError> {
336        let mut errors = Vec::new();
337        for (id, adapter) in &mut self.links {
338            if let Err(err) = adapter.probe(session) {
339                errors.push((*id, err));
340                continue;
341            }
342            match adapter.wake(session, letter) {
343                Ok(outcome) => return Ok((*id, outcome)),
344                Err(err) => errors.push((*id, err)),
345            }
346        }
347        Err(ChainError(errors))
348    }
349}
350
351/// Builds the ordered chain for `session` on `host` ([`mechanisms`] order).
352pub fn plan_chain(session: &ProviderSession, host: &HostEnv, config: &AdapterConfig) -> WakeChain {
353    let kind = session.kind;
354    let inbox = config.inbox_dir.clone();
355    let mut chain = WakeChain::new();
356    for row in mechanisms(kind, host.vendor) {
357        let adapter: Box<dyn WakeAdapter> = match row.id {
358            "grok-leader-acp" => Box::new(GrokLeaderAdapter {
359                leader_sock: config.leader_sock.clone(),
360            }),
361            "codex-app-server-turn" => Box::new(CodexAppServerAdapter::new(config.codex.clone())),
362            "kimi-acp-host" => Box::new(KimiAcpAdapter::new(
363                config.kimi_acp_bin.clone().map(|bin| KimiAcpConfig { bin }),
364            )),
365            "kimi-server-prompt" => Box::new(KimiServerAdapter::new(
366                config.kimi_url.clone(),
367                config.kimi_bearer.clone(),
368            )),
369            "claude-channel" => Box::new(ClaudeChannelAdapter::new(inbox.clone())),
370            "routine-webhook" => Box::new(RoutineWebhookAdapter {
371                provider: kind.provider,
372                url: config.routine_url.clone(),
373                bearer: config.routine_bearer.clone(),
374            }),
375            "claude-async-rewake" => hook(kind, HookFlavor::ClaudeRewake, &inbox),
376            "claude-stop-hook" => hook(kind, HookFlavor::ClaudeStop, &inbox),
377            "codex-stop-hook" => hook(kind, HookFlavor::CodexStop, &inbox),
378            "kimi-stop-hook" => hook(kind, HookFlavor::KimiStop, &inbox),
379            "cursor-stop-followup" => hook(kind, HookFlavor::CursorStop, &inbox),
380            "grok-stop-hook" => hook(kind, HookFlavor::GrokStop, &inbox),
381            "inbox-queue" => Box::new(InboxQueueAdapter::new(kind, inbox.clone())),
382            "claude-routine-fire" => Box::new(ClaudeRoutineFireAdapter {
383                url: config.claude_fire_url.clone(),
384                bearer: config.claude_fire_bearer.clone(),
385            }),
386            "no-inbound" => Box::new(NoInboundAdapter {
387                provider: kind.provider,
388            }),
389            id if id.ends_with("-spawn") || id == "codex-cloud-exec" => {
390                let program = config.spawn_program.clone().or_else(|| {
391                    std::env::var_os(super::spawn::bin_env(kind.provider))
392                        .filter(|value| !value.is_empty())
393                        .map(PathBuf::from)
394                });
395                let cloud_env = config.codex_cloud_env.clone().or_else(|| {
396                    std::env::var(super::spawn::CODEX_CLOUD_ENV_ENV)
397                        .ok()
398                        .filter(|value| !value.is_empty())
399                });
400                Box::new(ResumeSpawnAdapter::new(kind, program, cloud_env))
401            }
402            _ => continue,
403        };
404        chain.push(row.id, adapter);
405    }
406    chain
407}
408
409/// Chain for a session the WEB machine client holds, when the session has
410/// no webhook routine: only on a detected web vendor host other than the
411/// Grok Bot box (which wakes through its routine), with the provider read
412/// from the CLI's env markers. `None` keeps today's behaviour.
413pub fn detected_web_chain(
414    session_id: &str,
415    nick: &str,
416    store_root: &Path,
417) -> Option<(ProviderSession, WakeChain)> {
418    let host = HostEnv::detect();
419    if host.surface != Surface::Web || host.vendor == Some(WebVendor::GrokBot) {
420        return None;
421    }
422    let provider = detect_provider(|key| std::env::var(key).ok())?;
423    let session = ProviderSession {
424        kind: SessionKind::web(provider),
425        session_id: session_id.to_string(),
426        nick: nick.to_string(),
427        cwd: std::env::current_dir().ok(),
428        headless: std::env::var(HEADLESS_ENV).is_ok_and(|v| v == "1"),
429    };
430    let config = AdapterConfig::from_env(Some(super::registry::inbox_dir(store_root, session_id)));
431    let chain = plan_chain(&session, &host, &config);
432    Some((session, chain))
433}
434
435fn hook(kind: SessionKind, flavor: HookFlavor, inbox: &Option<PathBuf>) -> Box<dyn WakeAdapter> {
436    Box::new(InboxHookAdapter::new(kind, flavor, inbox.clone()))
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442    #[cfg(feature = "wake-claude")]
443    use crate::inbox;
444    use crate::tests::{letter, session};
445
446    fn env<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
447        move |key| {
448            pairs
449                .iter()
450                .find(|(k, _)| *k == key)
451                .map(|(_, v)| v.to_string())
452        }
453    }
454
455    #[test]
456    fn host_detection_orders_overrides_then_markers() {
457        let none = |_: &Path| false;
458        assert_eq!(HostEnv::detect_from(env(&[]), none).surface, Surface::Local);
459        let claude = HostEnv::detect_from(env(&[("CLAUDE_CODE_REMOTE", "true")]), none);
460        assert_eq!(
461            (claude.surface, claude.vendor),
462            (Surface::Web, Some(WebVendor::ClaudeWeb))
463        );
464        let bot = HostEnv::detect_from(env(&[]), |p: &Path| p.ends_with("agent-data"));
465        assert_eq!(bot.vendor, Some(WebVendor::GrokBot));
466        let forced = HostEnv::detect_from(env(&[(SURFACE_ENV, "local")]), |_: &Path| true);
467        assert_eq!((forced.surface, forced.vendor), (Surface::Local, None));
468        let codex = HostEnv::detect_from(env(&[(WEB_VENDOR_ENV, "codex-cloud")]), none);
469        assert_eq!(codex.vendor, Some(WebVendor::CodexCloud));
470    }
471
472    #[test]
473    fn provider_detection_uses_cli_markers() {
474        assert_eq!(
475            detect_provider(env(&[("CLAUDECODE", "1")])),
476            Some(ProviderKind::ClaudeCode)
477        );
478        assert_eq!(
479            detect_provider(env(&[("CODEX_THREAD_ID", "t")])),
480            Some(ProviderKind::Codex)
481        );
482        assert_eq!(
483            detect_provider(env(&[("CURSOR_AGENT", "1")])),
484            Some(ProviderKind::Cursor)
485        );
486        assert_eq!(
487            detect_provider(env(&[("M4A_PROVIDER", "kimi"), ("CLAUDECODE", "1")])),
488            Some(ProviderKind::KimiCode)
489        );
490        assert_eq!(detect_provider(env(&[])), None);
491        assert_eq!(
492            detect_session_id(ProviderKind::Codex, env(&[("CODEX_THREAD_ID", "t9")])).as_deref(),
493            Some("t9")
494        );
495    }
496
497    #[test]
498    fn every_chain_puts_in_session_first_and_spawn_last() {
499        for provider in ProviderKind::ALL {
500            for (kind, vendor) in [
501                (SessionKind::local(provider), None),
502                (SessionKind::web(provider), Some(WebVendor::GrokBot)),
503                (SessionKind::web(provider), Some(WebVendor::ClaudeWeb)),
504            ] {
505                let rows = mechanisms(kind, vendor);
506                assert!(!rows.is_empty(), "{kind}");
507                let tiers: Vec<Tier> = rows.iter().map(|row| row.tier).collect();
508                let rank = |t: &Tier| match t {
509                    Tier::InSession => 0,
510                    Tier::Hook => 1,
511                    Tier::Queue => 2,
512                    Tier::Spawn => 3,
513                };
514                assert!(
515                    tiers.windows(2).all(|w| rank(&w[0]) <= rank(&w[1])),
516                    "{kind}: {tiers:?}"
517                );
518                let host = HostEnv {
519                    surface: kind.surface,
520                    vendor,
521                    os: "linux",
522                };
523                let chain = plan_chain(&session(kind), &host, &AdapterConfig::default());
524                let ids: Vec<&str> = rows.iter().map(|row| row.id).collect();
525                assert_eq!(chain.ids(), ids, "{kind}");
526            }
527        }
528    }
529
530    #[cfg(feature = "wake-claude")]
531    #[test]
532    fn chain_falls_through_to_durable_queue_without_spawning() {
533        let dir = std::env::temp_dir().join(format!("m4a-chain-{}", std::process::id()));
534        let _ = std::fs::remove_dir_all(&dir);
535        let kind = SessionKind::local(ProviderKind::ClaudeCode);
536        let config = AdapterConfig {
537            inbox_dir: Some(dir.clone()),
538            spawn_program: Some("/bin/false".into()),
539            ..AdapterConfig::default()
540        };
541        let host = HostEnv {
542            surface: Surface::Local,
543            vendor: None,
544            os: "linux",
545        };
546        let s = session(kind);
547        let mut chain = plan_chain(&s, &host, &config);
548        let (id, outcome) = chain.wake(&s, &letter("hi")).unwrap();
549        assert_eq!(id, "inbox-queue");
550        assert!(matches!(outcome, WakeOutcome::Queued(_)));
551        // With a live channel server the primary wins.
552        let _live = inbox::Presence::announce(&dir, inbox::consumer::CLAUDE_CHANNEL).unwrap();
553        assert_eq!(chain.best(&s), Some("claude-channel"));
554        let (id, _) = chain.wake(&s, &letter("hi")).unwrap();
555        assert_eq!(id, "claude-channel");
556        let _ = std::fs::remove_dir_all(&dir);
557    }
558
559    #[test]
560    fn chain_without_any_endpoint_reports_every_link() {
561        let kind = SessionKind::web(ProviderKind::KimiCode);
562        let host = HostEnv {
563            surface: Surface::Web,
564            vendor: None,
565            os: "linux",
566        };
567        let s = session(kind);
568        let mut chain = plan_chain(&s, &host, &AdapterConfig::default());
569        let err = chain.wake(&s, &letter("x")).unwrap_err();
570        assert_eq!(err.0.len(), 1);
571        assert!(err.to_string().contains("no inbound"));
572    }
573
574    /// Live: `M4A_LIVE_CODEX_APP_SERVER` (unix://PATH or ws://127.0.0.1:PORT)
575    /// and `M4A_LIVE_CODEX_THREAD` (a thread an attached client has open).
576    #[test]
577    #[ignore]
578    fn live_codex_chain_turns_in_the_open_thread() {
579        let endpoint = std::env::var("M4A_LIVE_CODEX_APP_SERVER").expect("endpoint");
580        let thread = std::env::var("M4A_LIVE_CODEX_THREAD").expect("thread");
581        let kind = SessionKind::local(ProviderKind::Codex);
582        let mut s = session(kind);
583        s.session_id = thread;
584        let config = AdapterConfig {
585            codex: super::super::CodexEndpoint::parse(&endpoint),
586            spawn_program: Some("/bin/false".into()),
587            ..AdapterConfig::default()
588        };
589        let host = HostEnv {
590            surface: Surface::Local,
591            vendor: None,
592            os: std::env::consts::OS,
593        };
594        let mut chain = plan_chain(&s, &host, &config);
595        let (id, outcome) = chain
596            .wake(&s, &letter("live chain ping, reply with one word"))
597            .unwrap();
598        assert_eq!(
599            (id, outcome),
600            ("codex-app-server-turn", WakeOutcome::Delivered)
601        );
602    }
603
604    /// Live: `M4A_LIVE_KIMI_URL`, `M4A_LIVE_KIMI_TOKEN`, `M4A_LIVE_KIMI_SESSION`
605    /// (a session open in `kimi web`), or discovery from `~/.kimi-code`.
606    #[test]
607    #[ignore]
608    fn live_kimi_chain_prompts_the_open_session() {
609        let kind = SessionKind::local(ProviderKind::KimiCode);
610        let mut s = session(kind);
611        s.session_id = std::env::var("M4A_LIVE_KIMI_SESSION").expect("session");
612        let mut config = AdapterConfig::from_env(None);
613        if let (Ok(url), Ok(token)) = (
614            std::env::var("M4A_LIVE_KIMI_URL"),
615            std::env::var("M4A_LIVE_KIMI_TOKEN"),
616        ) {
617            config.kimi_url = Some(url);
618            config.kimi_bearer = Some(token);
619        }
620        config.spawn_program = Some("/bin/false".into());
621        let host = HostEnv {
622            surface: Surface::Local,
623            vendor: None,
624            os: std::env::consts::OS,
625        };
626        let mut chain = plan_chain(&s, &host, &config);
627        let (id, outcome) = chain.wake(&s, &letter("live chain ping")).unwrap();
628        assert_eq!(
629            (id, outcome),
630            ("kimi-server-prompt", WakeOutcome::Delivered)
631        );
632    }
633}