Skip to main content

agent_status/agents/
mod.rs

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