Skip to main content

agent_status/agents/
mod.rs

1pub mod claude_code;
2pub mod oh_my_pi;
3pub mod opencode;
4pub mod pi;
5
6/// CLI-facing enumeration of every registered agent.
7///
8/// Mirrors the [`Agent`] trait registry but lives in the type system so the
9/// `--agent` flag, [`AgentName::agent`] dispatch, and the
10/// [`crate::commands::build_extension`] match are all compile-time checked.
11/// Wire strings (`claude-code`, etc.) match each variant's `Agent::name()`
12/// return value so on-disk state files and CLI flags use the same identifiers.
13#[derive(Clone, Copy, Debug, Eq, PartialEq, clap::ValueEnum)]
14pub enum AgentName {
15    #[value(name = "claude-code")]
16    ClaudeCode,
17    #[value(name = "oh-my-pi")]
18    OhMyPi,
19    #[value(name = "pi")]
20    Pi,
21    #[value(name = "opencode")]
22    Opencode,
23}
24
25impl AgentName {
26    /// Stable wire string for this agent (matches `Agent::name()`).
27    #[must_use]
28    pub fn name(self) -> &'static str {
29        match self {
30            Self::ClaudeCode => "claude-code",
31            Self::OhMyPi => "oh-my-pi",
32            Self::Pi => "pi",
33            Self::Opencode => "opencode",
34        }
35    }
36
37    /// Dispatch to the trait object for this agent. Equivalent to
38    /// `by_name(self.name()).expect("AgentName must always resolve")` but
39    /// the compiler knows it can't fail.
40    #[must_use]
41    pub fn agent(self) -> Box<dyn Agent> {
42        match self {
43            Self::ClaudeCode => Box::new(claude_code::ClaudeCodeAgent),
44            Self::OhMyPi => Box::new(oh_my_pi::OhMyPiAgent),
45            Self::Pi => Box::new(pi::PiAgent),
46            Self::Opencode => Box::new(opencode::OpencodeAgent),
47        }
48    }
49}
50
51/// An agent implementation: knows how to extract a session ID from the JSON payload that
52/// agent's hook delivers on stdin.
53pub trait Agent {
54    /// Stable, lowercase, hyphenated identifier (e.g. `"claude-code"`). Used for the
55    /// `--agent` CLI flag and the `agent` field on persisted entries.
56    fn name(&self) -> &'static str;
57
58    /// Extract the session ID from the agent's hook event JSON. Returns `None` for
59    /// invalid JSON, missing field, non-string value, or empty string.
60    fn extract_session_id(&self, stdin_json: &str) -> Option<String>;
61
62    /// Extract the agent's last-response text from the hook event JSON, when the
63    /// payload carries one. Returns `None` when the field is absent, empty, or
64    /// non-string. Default returns `None`; override in agents whose payload includes
65    /// such text.
66    fn extract_message(&self, _stdin_json: &str) -> Option<String> {
67        None
68    }
69}
70
71/// Resolve an agent by its `--agent` flag value.
72#[must_use]
73pub fn by_name(name: &str) -> Option<Box<dyn Agent>> {
74    match name {
75        "claude-code" => Some(Box::new(claude_code::ClaudeCodeAgent)),
76        "oh-my-pi" => Some(Box::new(oh_my_pi::OhMyPiAgent)),
77        "opencode" => Some(Box::new(opencode::OpencodeAgent)),
78        "pi" => Some(Box::new(pi::PiAgent)),
79        _ => None,
80    }
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86
87    #[test]
88    fn by_name_resolves_claude_code() {
89        let agent = by_name("claude-code").expect("claude-code is a registered agent");
90        assert_eq!(agent.name(), "claude-code");
91    }
92
93    #[test]
94    fn by_name_resolves_oh_my_pi() {
95        let agent = by_name("oh-my-pi").expect("oh-my-pi is a registered agent");
96        assert_eq!(agent.name(), "oh-my-pi");
97    }
98
99    #[test]
100    fn by_name_returns_none_for_unknown() {
101        assert!(by_name("frobnicator").is_none());
102    }
103
104    #[test]
105    fn by_name_is_case_sensitive() {
106        assert!(by_name("Claude-Code").is_none());
107    }
108
109    #[test]
110    fn by_name_resolves_pi() {
111        let agent = by_name("pi").expect("pi is a registered agent");
112        assert_eq!(agent.name(), "pi");
113    }
114
115    #[test]
116    fn by_name_resolves_opencode() {
117        let agent = by_name("opencode").expect("opencode is a registered agent");
118        assert_eq!(agent.name(), "opencode");
119    }
120
121    #[test]
122    fn agent_name_returns_wire_string_for_each_variant() {
123        assert_eq!(AgentName::ClaudeCode.name(), "claude-code");
124        assert_eq!(AgentName::OhMyPi.name(), "oh-my-pi");
125        assert_eq!(AgentName::Pi.name(), "pi");
126        assert_eq!(AgentName::Opencode.name(), "opencode");
127    }
128
129    #[test]
130    fn agent_name_dispatch_returns_matching_trait_object() {
131        assert_eq!(AgentName::ClaudeCode.agent().name(), "claude-code");
132        assert_eq!(AgentName::OhMyPi.agent().name(), "oh-my-pi");
133        assert_eq!(AgentName::Pi.agent().name(), "pi");
134        assert_eq!(AgentName::Opencode.agent().name(), "opencode");
135    }
136
137    #[test]
138    fn agent_name_parses_kebab_case_via_value_enum() {
139        use clap::ValueEnum;
140        assert_eq!(
141            AgentName::from_str("claude-code", true).unwrap(),
142            AgentName::ClaudeCode,
143        );
144        assert_eq!(
145            AgentName::from_str("oh-my-pi", true).unwrap(),
146            AgentName::OhMyPi,
147        );
148        assert_eq!(AgentName::from_str("pi", true).unwrap(), AgentName::Pi);
149        assert_eq!(
150            AgentName::from_str("opencode", true).unwrap(),
151            AgentName::Opencode,
152        );
153        assert!(AgentName::from_str("frobnicator", true).is_err());
154    }
155
156    #[test]
157    fn extract_message_default_returns_none() {
158        // A hand-rolled agent that doesn't override extract_message should get None.
159        struct NoopAgent;
160        impl Agent for NoopAgent {
161            fn name(&self) -> &'static str { "noop" }
162            fn extract_session_id(&self, _: &str) -> Option<String> { None }
163        }
164        assert!(NoopAgent.extract_message(r#"{"message":"hi"}"#).is_none());
165    }
166}