Skip to main content

supercode_interchange/ontology/
binding.rs

1//! The one conversation record (`docs/ONTOLOGY.md` §2.2): a conversation
2//! reached on a surface, routed to a profile, run by a worker, with a
3//! lifecycle. A Hermes `sessions` row, an OpenClaw session key and an
4//! orchestrator `bindings` row each decode to it ONCE, here; the discovery
5//! row's nouns are its projection ([`Binding::nouns`]).
6
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9
10use super::residue::Residue;
11use super::surface::{CrossSurface, Recurrence, SurfaceKey, Trigger};
12use super::HarnessId;
13use crate::session::OrchestrationNouns;
14
15/// Why a binding ended (`docs/ORCHESTRATOR-IR.md` §2.5).
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
17#[serde(rename_all = "snake_case")]
18pub enum EndReason {
19    /// Idle expiry.
20    Idle,
21    /// The daily reset boundary.
22    Daily,
23    /// `/reset` (or the operator verb).
24    Reset,
25    /// `/new` (or the operator verb).
26    New,
27    /// Moved to another surface.
28    Handoff,
29    /// The worker failed.
30    Error,
31}
32
33impl EndReason {
34    /// Parse the wire word; anything else is not an end reason.
35    pub fn parse(word: &str) -> Option<Self> {
36        Some(match word {
37            "idle" => Self::Idle,
38            "daily" => Self::Daily,
39            "reset" => Self::Reset,
40            "new" => Self::New,
41            "handoff" => Self::Handoff,
42            "error" => Self::Error,
43            _ => return None,
44        })
45    }
46
47    /// The wire word.
48    pub fn as_str(self) -> &'static str {
49        match self {
50            Self::Idle => "idle",
51            Self::Daily => "daily",
52            Self::Reset => "reset",
53            Self::New => "new",
54            Self::Handoff => "handoff",
55            Self::Error => "error",
56        }
57    }
58}
59
60/// The worker session a binding points at.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
62pub struct Worker {
63    /// Which harness runs the conversation.
64    pub harness: HarnessId,
65    /// The harness's own session id, when one exists yet.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub session_id: Option<String>,
68    /// Where that session's transcript can be read (a path or store address).
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub locator: Option<String>,
71}
72
73/// A conversation moved (or moving) to another surface — Hermes `handoff_*`.
74#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
75pub struct Handoff {
76    /// The destination platform.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub to: Option<String>,
79    /// `pending` | `done` | `failed` (the source's own word, verbatim).
80    pub state: String,
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    /// The failure, when `state` is `failed`.
83    pub error: Option<String>,
84}
85
86impl Default for Worker {
87    fn default() -> Self {
88        Self {
89            harness: HarnessId::new(""),
90            session_id: None,
91            locator: None,
92        }
93    }
94}
95
96/// The conversation record.
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
98pub struct Binding {
99    /// The surface; degenerate (all `None`) on a terminal.
100    pub key: SurfaceKey,
101    /// The profile that owns it (`None` = the home's default).
102    #[serde(default)]
103    pub profile: Option<String>,
104    /// The worker session.
105    pub worker: Worker,
106    /// Why the conversation exists. The orchestrator's own store keeps no
107    /// trigger column (it is derived from the key and recurrence), so the
108    /// wire may omit it.
109    #[serde(default)]
110    pub trigger: Trigger,
111    #[serde(default)]
112    /// The job this conversation is a fire of.
113    pub recurrence: Option<Recurrence>,
114    #[serde(default)]
115    /// Moved (or moving) to another surface.
116    pub handoff: Option<Handoff>,
117    /// RFC3339 instants where the source has them.
118    #[serde(default)]
119    pub started_at: Option<String>,
120    #[serde(default)]
121    /// Last activity, RFC3339.
122    pub last_activity_at: Option<String>,
123    #[serde(default)]
124    /// End, RFC3339, when ended.
125    pub ended_at: Option<String>,
126    #[serde(default)]
127    /// Why it ended.
128    pub end_reason: Option<EndReason>,
129    /// Source fields the record does not model, verbatim.
130    #[serde(default)]
131    pub residue: Residue,
132}
133
134impl Default for Binding {
135    fn default() -> Self {
136        Self {
137            key: SurfaceKey::default(),
138            profile: None,
139            worker: Worker::default(),
140            trigger: Trigger::Unknown,
141            recurrence: None,
142            handoff: None,
143            started_at: None,
144            last_activity_at: None,
145            ended_at: None,
146            end_reason: None,
147            residue: Residue::default(),
148        }
149    }
150}
151
152impl Binding {
153    /// The surface as a discovery row shows it: `None` on a terminal, where the
154    /// key carries no key string, platform or chat id.
155    pub fn surface(&self) -> Option<SurfaceKey> {
156        let k = &self.key;
157        if k.key.is_some() || k.platform.is_some() || k.chat_id.is_some() {
158            Some(k.clone())
159        } else {
160            None
161        }
162    }
163
164    /// The discovery row's nouns. `workspace` is left for the row's own cwd
165    /// rule (`SessionMeta::workspace`), the one derivation that is not this
166    /// record's to make.
167    pub fn nouns(&self) -> OrchestrationNouns {
168        OrchestrationNouns {
169            trigger: Some(self.trigger),
170            surface: self.surface(),
171            profile: self.profile.clone(),
172            recurrence: self.recurrence.clone(),
173            cross_surface: self.handoff.as_ref().map(|h| CrossSurface {
174                state: h.state.clone(),
175                platform: h.to.clone(),
176                error: h.error.clone(),
177            }),
178            workspace: None,
179        }
180    }
181}
182
183// ------------------------------------------------------------- Hermes rows
184
185/// One Hermes `sessions` row, the columns a binding is made of. Empty strings
186/// are read as absent by the decoder.
187#[derive(Debug, Clone, Default)]
188pub struct HermesSessionRow {
189    /// `sessions.id`.
190    pub id: String,
191    /// `sessions.source`: `cli` | `tui` | `acp` | `api_server` | `cron` | `webhook` | a platform.
192    pub source: Option<String>,
193    /// `delegate` for a subagent child (Hermes lineage), else anything.
194    pub lineage_kind: Option<String>,
195    /// `sessions.session_key`, the gateway conversation key.
196    pub session_key: Option<String>,
197    /// `sessions.chat_id`.
198    pub chat_id: Option<String>,
199    /// `sessions.chat_type`.
200    pub chat_type: Option<String>,
201    /// `sessions.thread_id`.
202    pub thread_id: Option<String>,
203    /// `sessions.user_id` (the participant).
204    pub user_id: Option<String>,
205    /// `sessions.profile_name`, the routed profile.
206    pub profile_name: Option<String>,
207    /// `sessions.handoff_state`.
208    pub handoff_state: Option<String>,
209    /// `sessions.handoff_platform`.
210    pub handoff_platform: Option<String>,
211    /// `sessions.handoff_error`.
212    pub handoff_error: Option<String>,
213    /// Epoch seconds, as the store keeps them.
214    pub started_at: Option<f64>,
215    /// Epoch seconds when the session ended, if it has.
216    pub ended_at: Option<f64>,
217    /// `sessions.end_reason`, the store's own word.
218    pub end_reason: Option<String>,
219}
220
221/// Hermes `sessions.source` → trigger. Cron fires are tagged `cron`; the CLI,
222/// TUI and ACP adapter are human surfaces; `api_server` is the HTTP API;
223/// `webhook` is inbound; every other value is a messaging platform.
224pub fn hermes_trigger_for_source(source: &str) -> Trigger {
225    match source {
226        "" => Trigger::Unknown,
227        "cron" => Trigger::Cron,
228        "webhook" => Trigger::Webhook,
229        "cli" | "tui" | "acp" | "console" => Trigger::Human,
230        "api_server" | "api" => Trigger::Api,
231        "kanban" => Trigger::Task,
232        _ => Trigger::Channel,
233    }
234}
235
236/// Hermes cron fire session ids are minted as `cron_<job_id>_<YYYYMMDD_HHMMSS>`
237/// (`cron/scheduler.py`); recover the job id.
238pub fn hermes_cron_job_id(session_id: &str) -> Option<String> {
239    let rest = session_id.strip_prefix("cron_")?;
240    let (job, stamp) = rest.rsplit_once('_')?;
241    let (job, date) = job.rsplit_once('_')?;
242    let ok = date.len() == 8
243        && stamp.len() == 6
244        && date.chars().all(|c| c.is_ascii_digit())
245        && stamp.chars().all(|c| c.is_ascii_digit());
246    if ok && !job.is_empty() {
247        Some(job.to_string())
248    } else {
249        None
250    }
251}
252
253/// Parse a Hermes gateway session key
254/// (`agent:<profile|main>:<platform>:<chat_type>[:<chat_id>][:<thread_id>][:<participant>]`).
255/// Returns the surface and the profile namespace (`None` for `main`).
256pub fn parse_hermes_session_key(key: &str) -> Option<(SurfaceKey, Option<String>)> {
257    let parts: Vec<&str> = key.split(':').collect();
258    if parts.len() < 4 || parts[0] != "agent" {
259        return None;
260    }
261    let profile = match parts[1] {
262        "" | "main" | "default" => None,
263        p => Some(p.to_string()),
264    };
265    let surface = SurfaceKey {
266        key: Some(key.to_string()),
267        platform: Some(parts[2].to_string()),
268        kind: Some(parts[3].to_string()),
269        chat_id: parts.get(4).map(|s| s.to_string()),
270        thread_id: parts.get(5).map(|s| s.to_string()),
271        participant_id: parts.get(6).map(|s| s.to_string()),
272    };
273    Some((surface, profile))
274}
275
276/// Render a surface as Hermes's `build_session_key` form, which
277/// [`parse_hermes_session_key`] reads back unchanged.
278pub fn render_hermes_session_key(profile: &str, key: &SurfaceKey) -> String {
279    let mut parts = vec![
280        "agent".to_string(),
281        if profile.is_empty() {
282            "main".to_string()
283        } else {
284            profile.to_string()
285        },
286        key.platform.clone().unwrap_or_default(),
287        key.kind.clone().unwrap_or_default(),
288    ];
289    parts.extend(
290        [
291            key.chat_id.clone(),
292            key.thread_id.clone(),
293            key.participant_id.clone(),
294        ]
295        .into_iter()
296        .flatten(),
297    );
298    parts.join(":")
299}
300
301fn epoch_to_rfc3339(seconds: f64) -> String {
302    let millis = (seconds * 1000.0).round() as i64;
303    let secs = millis.div_euclid(1000);
304    let sub = millis.rem_euclid(1000) as u32;
305    // civil-from-days (Howard Hinnant), enough for a timestamp string
306    let days = secs.div_euclid(86_400);
307    let sod = secs.rem_euclid(86_400);
308    let z = days + 719_468;
309    let era = z.div_euclid(146_097);
310    let doe = z - era * 146_097;
311    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
312    let y = yoe + era * 400;
313    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
314    let mp = (5 * doy + 2) / 153;
315    let d = doy - (153 * mp + 2) / 5 + 1;
316    let m = if mp < 10 { mp + 3 } else { mp - 9 };
317    let y = if m <= 2 { y + 1 } else { y };
318    format!(
319        "{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{sub:03}Z",
320        sod / 3600,
321        (sod % 3600) / 60,
322        sod % 60
323    )
324}
325
326impl Binding {
327    /// Decode a Hermes `sessions` row. Always yields a record: a terminal
328    /// session is a binding with a degenerate key. The columns win over the
329    /// parsed key when both are present (ORC-8 finding: `api_server`
330    /// conversations carry the surface only in `session_key`).
331    pub fn from_hermes_row(row: &HermesSessionRow, locator: Option<&str>) -> Self {
332        let nonempty = |v: &Option<String>| v.clone().filter(|s| !s.is_empty());
333        let source = nonempty(&row.source).unwrap_or_default();
334        let mut trigger = hermes_trigger_for_source(&source);
335        if row.lineage_kind.as_deref() == Some("delegate") {
336            trigger = Trigger::Parent;
337        }
338        let mut recurrence = None;
339        if let Some(job_id) = hermes_cron_job_id(&row.id) {
340            recurrence = Some(Recurrence {
341                job_id,
342                kind: "cron".into(),
343            });
344            trigger = Trigger::Cron;
345        }
346        let mut profile = None;
347        let mut key = nonempty(&row.session_key)
348            .and_then(|k| parse_hermes_session_key(&k))
349            .map(|(surface, key_profile)| {
350                profile = key_profile;
351                surface
352            })
353            .unwrap_or_default();
354        if key.key.is_none() {
355            key.key = nonempty(&row.session_key);
356        }
357        if let Some(v) = nonempty(&row.chat_id) {
358            key.chat_id = Some(v);
359        }
360        if let Some(v) = nonempty(&row.chat_type) {
361            key.kind = Some(v);
362        }
363        if let Some(v) = nonempty(&row.thread_id) {
364            key.thread_id = Some(v);
365        }
366        if let Some(v) = nonempty(&row.user_id) {
367            key.participant_id = Some(v);
368        }
369        if key.platform.is_none() && trigger == Trigger::Channel {
370            key.platform = Some(source.clone());
371        }
372        if let Some(p) = nonempty(&row.profile_name) {
373            profile = Some(p);
374        }
375        let handoff = nonempty(&row.handoff_state).map(|state| Handoff {
376            to: nonempty(&row.handoff_platform),
377            state,
378            error: nonempty(&row.handoff_error),
379        });
380        let mut residue = Residue::default();
381        let end_reason = match nonempty(&row.end_reason) {
382            Some(word) => match EndReason::parse(&word) {
383                Some(r) => Some(r),
384                None => {
385                    residue.keep("end_reason", serde_json::Value::String(word));
386                    None
387                }
388            },
389            None => None,
390        };
391        Self {
392            key,
393            profile,
394            worker: Worker {
395                harness: HarnessId::new(HarnessId::HERMES),
396                session_id: Some(row.id.clone()),
397                locator: locator.map(str::to_string),
398            },
399            trigger,
400            recurrence,
401            handoff,
402            started_at: row.started_at.map(epoch_to_rfc3339),
403            last_activity_at: row.ended_at.or(row.started_at).map(epoch_to_rfc3339),
404            ended_at: row.ended_at.map(epoch_to_rfc3339),
405            end_reason,
406            residue,
407        }
408    }
409}
410
411/// The Hermes `sessions.source` word for a binding (inverse of
412/// [`hermes_trigger_for_source`]): a channel conversation's source is its
413/// platform; the rest are the words Hermes's own doors mint.
414pub fn hermes_source_for_binding(binding: &Binding) -> String {
415    match binding.trigger {
416        Trigger::Cron => "cron".into(),
417        Trigger::Webhook => "webhook".into(),
418        Trigger::Api => "api_server".into(),
419        Trigger::Task => "kanban".into(),
420        Trigger::Human => "cli".into(),
421        Trigger::Parent => "delegate".into(),
422        Trigger::Heartbeat => "heartbeat".into(),
423        Trigger::Channel | Trigger::Unknown => {
424            binding.key.platform.clone().unwrap_or_else(|| "cli".into())
425        }
426    }
427}
428
429// ----------------------------------------------------------- OpenClaw keys
430
431/// Render an OpenClaw gateway session key for a binding under an agent
432/// (inverse of [`parse_openclaw_session_key`]): a cron fire is `cron:<jobId>`,
433/// a conversation is the agent shape.
434pub fn render_openclaw_session_key(agent: &str, binding: &Binding) -> String {
435    let key = &binding.key;
436    if let Some(k) = &key.key {
437        return k.clone();
438    }
439    if let Some(r) = &binding.recurrence {
440        return format!("cron:{}", r.job_id);
441    }
442    if key.kind.as_deref() == Some("main") || key.platform.is_none() {
443        return format!("agent:{agent}:main");
444    }
445    let mut out = format!(
446        "agent:{agent}:{}:{}:{}",
447        key.platform.clone().unwrap_or_default(),
448        key.kind.clone().unwrap_or_else(|| "dm".into()),
449        key.chat_id.clone().unwrap_or_default()
450    );
451    if let Some(t) = &key.thread_id {
452        out.push_str(&format!(":thread:{t}"));
453    }
454    out
455}
456
457/// Parse an OpenClaw gateway session key. Shapes (`docs/channels/channel-routing.md`,
458/// `docs/automation/cron-jobs.md`, `docs/cli/acp.md` upstream):
459/// `agent:<id>:main`, `agent:<id>:<channel>:<group|channel>:<cid>[:thread|topic:<tid>]`,
460/// `cron:<jobId>`, `hook:<name>:<id>`, `acp-bridge:<uuid>`.
461pub fn parse_openclaw_session_key(
462    key: &str,
463) -> Option<(Option<String>, SurfaceKey, Trigger, Option<Recurrence>)> {
464    let parts: Vec<&str> = key.split(':').collect();
465    match parts.first().copied() {
466        Some("agent") if parts.len() >= 3 => {
467            let agent = Some(parts[1].to_string());
468            if parts[2] == "main" {
469                let surface = SurfaceKey {
470                    key: Some(key.to_string()),
471                    kind: Some("main".to_string()),
472                    ..SurfaceKey::default()
473                };
474                return Some((agent, surface, Trigger::Unknown, None));
475            }
476            if parts.len() < 5 {
477                return None;
478            }
479            let thread_id = match (parts.get(5), parts.get(6)) {
480                (Some(&"thread"), Some(t)) | (Some(&"topic"), Some(t)) => Some(t.to_string()),
481                _ => None,
482            };
483            let surface = SurfaceKey {
484                key: Some(key.to_string()),
485                platform: Some(parts[2].to_string()),
486                kind: Some(parts[3].to_string()),
487                chat_id: Some(parts[4].to_string()),
488                thread_id,
489                participant_id: None,
490            };
491            Some((agent, surface, Trigger::Channel, None))
492        }
493        Some("cron") if parts.len() >= 2 => Some((
494            None,
495            SurfaceKey {
496                key: Some(key.to_string()),
497                ..SurfaceKey::default()
498            },
499            Trigger::Cron,
500            Some(Recurrence {
501                job_id: parts[1..].join(":"),
502                kind: "cron".into(),
503            }),
504        )),
505        Some("hook") if parts.len() >= 2 => Some((
506            None,
507            SurfaceKey {
508                key: Some(key.to_string()),
509                ..SurfaceKey::default()
510            },
511            Trigger::Webhook,
512            None,
513        )),
514        Some("acp-bridge") => Some((
515            None,
516            SurfaceKey {
517                key: Some(key.to_string()),
518                platform: Some("acp".into()),
519                ..SurfaceKey::default()
520            },
521            Trigger::Api,
522            None,
523        )),
524        _ => None,
525    }
526}
527
528impl Binding {
529    /// Decode an OpenClaw session key. `None` when the key has no known shape
530    /// (nothing is claimed, as before). `agent_from_path` is the profile the
531    /// file's own `agents/<id>/` directory names, used when the key names none.
532    pub fn from_openclaw_key(
533        key: &str,
534        agent_from_path: Option<&str>,
535        session_id: Option<&str>,
536        locator: Option<&str>,
537    ) -> Option<Self> {
538        let (agent, surface, trigger, recurrence) = parse_openclaw_session_key(key)?;
539        Some(Self {
540            key: surface,
541            profile: agent.or_else(|| agent_from_path.map(str::to_string)),
542            worker: Worker {
543                harness: HarnessId::new(HarnessId::OPENCLAW),
544                session_id: session_id.map(str::to_string),
545                locator: locator.map(str::to_string),
546            },
547            trigger,
548            recurrence,
549            ..Self::default()
550        })
551    }
552}
553
554// ------------------------------------------------------ orchestrator rows
555
556/// One row of an orchestrator profile's `bindings` table, as read.
557#[derive(Debug, Clone, Default)]
558pub struct OrchestratorBindingRow {
559    /// Surface platform.
560    pub platform: String,
561    /// Surface chat type (`dm` | `group` | `channel` | `thread`).
562    pub chat_type: String,
563    /// Surface chat id.
564    pub chat_id: Option<String>,
565    /// Surface thread id.
566    pub thread_id: Option<String>,
567    /// Surface participant id.
568    pub participant_id: Option<String>,
569    /// The worker harness.
570    pub worker_harness: String,
571    /// The worker session id; `None` until the worker has reported one.
572    pub worker_session_id: Option<String>,
573    /// Where the worker transcript can be read.
574    pub worker_locator: Option<String>,
575    /// RFC3339 start.
576    pub started_at: Option<String>,
577    /// RFC3339 last activity.
578    pub last_activity_at: Option<String>,
579    /// RFC3339 end, if ended.
580    pub ended_at: Option<String>,
581    /// Why it ended, the store's own word.
582    pub end_reason: Option<String>,
583    /// Handoff destination platform.
584    pub handoff_to: Option<String>,
585    /// Handoff state.
586    pub handoff_state: Option<String>,
587    /// Handoff error.
588    pub handoff_error: Option<String>,
589    /// The job a fire binding belongs to.
590    pub recurrence_job_id: Option<String>,
591}
592
593impl Binding {
594    /// Decode an orchestrator `bindings` row under `profile`. The key string is
595    /// the orchestrator's own rendering (`docs/ORCHESTRATOR-IR.md` §2.3), which
596    /// is Hermes's form, so [`parse_hermes_session_key`] reads it back unchanged.
597    pub fn from_orchestrator_row(profile: &str, row: &OrchestratorBindingRow) -> Self {
598        let mut key = SurfaceKey {
599            key: None,
600            platform: Some(row.platform.clone()),
601            kind: Some(row.chat_type.clone()),
602            chat_id: row.chat_id.clone(),
603            thread_id: row.thread_id.clone(),
604            participant_id: row.participant_id.clone(),
605        };
606        key.key = Some(render_hermes_session_key(profile, &key));
607        let trigger = if row.recurrence_job_id.is_some() {
608            Trigger::Cron
609        } else if row.platform == "webhook" {
610            Trigger::Webhook
611        } else {
612            Trigger::Channel
613        };
614        let mut residue = Residue::default();
615        let end_reason = match row.end_reason.as_deref() {
616            Some(word) => match EndReason::parse(word) {
617                Some(r) => Some(r),
618                None => {
619                    residue.keep("end_reason", serde_json::Value::String(word.to_string()));
620                    None
621                }
622            },
623            None => None,
624        };
625        Self {
626            key,
627            profile: Some(profile.to_string()),
628            worker: Worker {
629                harness: HarnessId::new(&row.worker_harness),
630                session_id: row.worker_session_id.clone().filter(|s| !s.is_empty()),
631                locator: row.worker_locator.clone(),
632            },
633            trigger,
634            recurrence: row.recurrence_job_id.clone().map(|job_id| Recurrence {
635                job_id,
636                kind: "cron".into(),
637            }),
638            handoff: row.handoff_state.clone().map(|state| Handoff {
639                to: row.handoff_to.clone(),
640                state,
641                error: row.handoff_error.clone(),
642            }),
643            started_at: row.started_at.clone(),
644            last_activity_at: row.last_activity_at.clone(),
645            ended_at: row.ended_at.clone(),
646            end_reason,
647            residue,
648        }
649    }
650}
651
652#[cfg(test)]
653mod tests {
654    use super::*;
655
656    #[test]
657    fn hermes_row_columns_win_over_the_key_and_api_server_keeps_its_key() {
658        let row = HermesSessionRow {
659            id: "s1".into(),
660            source: Some("telegram".into()),
661            session_key: Some("agent:coder:telegram:group:-100777:55".into()),
662            chat_id: Some("-100999".into()),
663            profile_name: Some("coder".into()),
664            started_at: Some(1_788_000_000.5),
665            ..Default::default()
666        };
667        let b = Binding::from_hermes_row(&row, Some("state.db"));
668        assert_eq!(b.trigger, Trigger::Channel);
669        assert_eq!(b.key.chat_id.as_deref(), Some("-100999"));
670        assert_eq!(b.key.thread_id.as_deref(), Some("55"));
671        assert_eq!(b.profile.as_deref(), Some("coder"));
672        assert_eq!(b.started_at.as_deref(), Some("2026-08-29T10:40:00.500Z"));
673        let n = b.nouns();
674        assert_eq!(
675            n.surface
676                .as_ref()
677                .and_then(|s| s.platform.clone())
678                .as_deref(),
679            Some("telegram")
680        );
681
682        let api = HermesSessionRow {
683            id: "s2".into(),
684            source: Some("api_server".into()),
685            session_key: Some("agent:main:chat:dm:ada-dm".into()),
686            ..Default::default()
687        };
688        let b = Binding::from_hermes_row(&api, None);
689        assert_eq!(b.trigger, Trigger::Api);
690        assert_eq!(b.key.chat_id.as_deref(), Some("ada-dm"));
691        assert_eq!(b.profile, None);
692    }
693
694    #[test]
695    fn hermes_cron_and_delegate_and_terminal_rows() {
696        let cron = HermesSessionRow {
697            id: "cron_job42_20260902_120000".into(),
698            source: Some("cron".into()),
699            ..Default::default()
700        };
701        let b = Binding::from_hermes_row(&cron, None);
702        assert_eq!(b.trigger, Trigger::Cron);
703        assert_eq!(
704            b.recurrence.as_ref().map(|r| r.job_id.as_str()),
705            Some("job42")
706        );
707        let child = HermesSessionRow {
708            id: "c".into(),
709            source: Some("cli".into()),
710            lineage_kind: Some("delegate".into()),
711            ..Default::default()
712        };
713        assert_eq!(
714            Binding::from_hermes_row(&child, None).trigger,
715            Trigger::Parent
716        );
717        let terminal = HermesSessionRow {
718            id: "t".into(),
719            source: Some("cli".into()),
720            end_reason: Some("weird".into()),
721            ..Default::default()
722        };
723        let b = Binding::from_hermes_row(&terminal, None);
724        assert_eq!(b.surface(), None, "a terminal session has a degenerate key");
725        assert_eq!(b.nouns().surface, None);
726        assert_eq!(b.end_reason, None);
727        assert_eq!(
728            b.residue.0.get("end_reason").and_then(|v| v.as_str()),
729            Some("weird")
730        );
731    }
732
733    #[test]
734    fn openclaw_keys_and_orchestrator_rows() {
735        let b = Binding::from_openclaw_key(
736            "agent:ops:telegram:group:-1:thread:7",
737            None,
738            Some("u1"),
739            None,
740        )
741        .unwrap();
742        assert_eq!(b.profile.as_deref(), Some("ops"));
743        assert_eq!(b.key.thread_id.as_deref(), Some("7"));
744        assert_eq!(b.trigger, Trigger::Channel);
745        let c = Binding::from_openclaw_key("cron:abc:def", Some("ops"), None, None).unwrap();
746        assert_eq!(
747            c.recurrence.as_ref().map(|r| r.job_id.as_str()),
748            Some("abc:def")
749        );
750        assert_eq!(c.profile.as_deref(), Some("ops"));
751        assert!(Binding::from_openclaw_key("nonsense", None, None, None).is_none());
752
753        let row = OrchestratorBindingRow {
754            platform: "telegram".into(),
755            chat_type: "dm".into(),
756            chat_id: Some("123456".into()),
757            worker_harness: "codex".into(),
758            worker_session_id: Some("sess-1".into()),
759            end_reason: Some("idle".into()),
760            ended_at: Some("2026-09-04T10:00:00.000Z".into()),
761            ..Default::default()
762        };
763        let b = Binding::from_orchestrator_row("default", &row);
764        assert_eq!(
765            b.key.key.as_deref(),
766            Some("agent:default:telegram:dm:123456")
767        );
768        assert_eq!(b.trigger, Trigger::Channel);
769        assert_eq!(b.end_reason, Some(EndReason::Idle));
770        let fire = OrchestratorBindingRow {
771            platform: "cron".into(),
772            chat_type: "dm".into(),
773            chat_id: Some("job42".into()),
774            recurrence_job_id: Some("job42".into()),
775            worker_harness: "hermes".into(),
776            worker_session_id: Some("f".into()),
777            ..Default::default()
778        };
779        assert_eq!(
780            Binding::from_orchestrator_row("default", &fire)
781                .nouns()
782                .trigger,
783            Some(Trigger::Cron)
784        );
785        let hook = OrchestratorBindingRow {
786            platform: "webhook".into(),
787            chat_type: "dm".into(),
788            worker_harness: "hermes".into(),
789            worker_session_id: Some("w".into()),
790            ..Default::default()
791        };
792        assert_eq!(
793            Binding::from_orchestrator_row("default", &hook).trigger,
794            Trigger::Webhook
795        );
796        // the rendered key parses back to the same surface
797        let (parsed, profile) = parse_hermes_session_key(b.key.key.as_deref().unwrap()).unwrap();
798        assert_eq!(parsed.chat_id, b.key.chat_id);
799        assert_eq!(profile, None);
800    }
801}