Skip to main content

oby_core/
wire.rs

1use crate::{DisplayEntry, DisplayEntryUpdate};
2use serde::{Deserialize, Serialize};
3
4/// Sent from oby-hook to the wrapper's control socket. JSON-line framed.
5#[derive(Debug, Clone, Serialize, Deserialize)]
6#[serde(tag = "kind", rename_all = "snake_case")]
7pub enum ControlMessage {
8    /// A new entry to add to the agent's timeline.
9    Entry { v: u8, entry: DisplayEntry },
10    /// Update to an existing entry (correlated by tool_use_id).
11    Update { v: u8, update: DisplayEntryUpdate },
12    /// A subagent has finished (its `SubagentStop` hook fired). The wrapper
13    /// flips that ring's `destroyed` flag so the status dot turns red.
14    AgentDestroyed { v: u8, agent_key: String },
15}
16
17impl ControlMessage {
18    pub fn entry(entry: DisplayEntry) -> Self {
19        Self::Entry { v: 1, entry }
20    }
21    pub fn update(update: DisplayEntryUpdate) -> Self {
22        Self::Update { v: 1, update }
23    }
24    pub fn agent_destroyed(agent_key: impl Into<String>) -> Self {
25        Self::AgentDestroyed {
26            v: 1,
27            agent_key: agent_key.into(),
28        }
29    }
30}
31
32/// Sent from oby-tee on connection open, before any bytes. One JSON line, then raw bytes until EOF.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct HeaderLine {
35    pub v: u8,
36    pub tool_use_id: String,
37    /// Sub-stream name: "stdout", "stderr", "stderr-discarded", etc. Capturer-defined.
38    pub stream: String,
39    /// Unix timestamp (seconds since epoch), prefixed with "@". v0.1 stopgap — no external chrono dep.
40    pub started_at: String,
41}
42
43impl HeaderLine {
44    pub fn new(tool_use_id: impl Into<String>, stream: impl Into<String>) -> Self {
45        use std::time::{SystemTime, UNIX_EPOCH};
46        let now = SystemTime::now()
47            .duration_since(UNIX_EPOCH)
48            .unwrap_or_default();
49        // Minimal timestamp marker — no external chrono dep. v0.1 just stores secs since epoch.
50        let started_at = format!("@{}", now.as_secs());
51        Self {
52            v: 1,
53            tool_use_id: tool_use_id.into(),
54            stream: stream.into(),
55            started_at,
56        }
57    }
58}
59
60#[cfg(test)]
61mod tests {
62    use super::*;
63    use crate::{EntryBody, EntryStatus};
64    use std::time::SystemTime;
65
66    #[test]
67    fn control_entry_roundtrips() {
68        let msg = ControlMessage::entry(DisplayEntry {
69            agent_key: "main".into(),
70            tool_use_id: "t1".into(),
71            tool: "bash".into(),
72            timestamp: SystemTime::UNIX_EPOCH,
73            headline: "ls".into(),
74            body: EntryBody::None,
75            status: EntryStatus::Pending,
76        });
77        let s = serde_json::to_string(&msg).unwrap();
78        assert!(s.contains("\"kind\":\"entry\""));
79        let parsed: ControlMessage = serde_json::from_str(&s).unwrap();
80        match parsed {
81            ControlMessage::Entry { v, entry } => {
82                assert_eq!(v, 1);
83                assert_eq!(entry.tool_use_id, "t1");
84            }
85            _ => panic!("expected Entry"),
86        }
87    }
88
89    #[test]
90    fn header_line_roundtrips() {
91        let h = HeaderLine::new("t1", "stderr-discarded");
92        let s = serde_json::to_string(&h).unwrap();
93        let parsed: HeaderLine = serde_json::from_str(&s).unwrap();
94        assert_eq!(parsed.tool_use_id, "t1");
95        assert_eq!(parsed.stream, "stderr-discarded");
96    }
97}