Skip to main content

muster/domain/process/
agent_protocol.rs

1use getset::Getters;
2use serde::{Deserialize, Serialize};
3use typed_builder::TypedBuilder;
4
5use super::AgentTool;
6use crate::domain::{
7    agent_session::{AgentSession, AgentSessionId, NativeSessionId},
8    value::CommandLine,
9};
10
11/// Current version of the public agent event protocol.
12pub const AGENT_PROTOCOL_VERSION: u8 = 1;
13/// Claude options whose following argument is part of the launch invocation.
14const CLAUDE_VALUE_OPTIONS: &[&str] = &[
15    "--model",
16    "--add-dir",
17    "--settings",
18    "--permission-mode",
19    "--fallback-model",
20];
21/// Codex options whose following argument is part of the launch invocation.
22const CODEX_VALUE_OPTIONS: &[&str] = &[
23    "-C",
24    "--cd",
25    "--sandbox",
26    "--ask-for-approval",
27    "--profile",
28    "--model",
29    "--config",
30    "--add-dir",
31    "--output-schema",
32];
33/// Gemini options whose following argument is part of the launch invocation.
34const GEMINI_VALUE_OPTIONS: &[&str] = &["--model"];
35
36/// Terminal evidence Muster should treat as inferred provider activity.
37#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
38pub enum AgentActivitySource {
39    /// Ordinary visible output indicates work.
40    #[default]
41    Output,
42    /// Terminal-title changes indicate work.
43    Title,
44}
45
46/// How a provider's native session identity becomes known.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum AgentIdentitySource {
49    /// Muster can assign its UUID when launching a new provider session.
50    Assigned,
51    /// A provider lifecycle event reports the identity after launch.
52    Reported,
53}
54
55/// Internal strategy defining what Muster launches and which signals it reads
56/// for one built-in agent provider.
57pub(crate) trait AgentProtocol {
58    /// Returns the provider's inferred terminal-activity source.
59    fn activity_source(&self) -> AgentActivitySource;
60
61    /// Returns how the provider's native session identity is obtained.
62    fn identity_source(&self) -> AgentIdentitySource;
63
64    /// Builds the command for a fresh provider conversation.
65    fn new_session_command(
66        &self,
67        command: &CommandLine,
68        session_id: &AgentSessionId,
69    ) -> Option<CommandLine>;
70
71    /// Builds the command for resuming a provider conversation.
72    fn resume_command(
73        &self,
74        command: &CommandLine,
75        native_id: &NativeSessionId,
76    ) -> Option<CommandLine>;
77}
78
79impl AgentProtocol for AgentTool {
80    fn activity_source(&self) -> AgentActivitySource {
81        match self {
82            Self::Codex | Self::Gemini | Self::Amp => AgentActivitySource::Title,
83            Self::Claude | Self::Opencode | Self::Copilot | Self::Kimi | Self::Custom => {
84                AgentActivitySource::Output
85            },
86        }
87    }
88
89    fn identity_source(&self) -> AgentIdentitySource {
90        match self {
91            Self::Claude => AgentIdentitySource::Assigned,
92            Self::Codex
93            | Self::Gemini
94            | Self::Amp
95            | Self::Opencode
96            | Self::Copilot
97            | Self::Kimi
98            | Self::Custom => AgentIdentitySource::Reported,
99        }
100    }
101
102    fn new_session_command(
103        &self,
104        command: &CommandLine,
105        session_id: &AgentSessionId,
106    ) -> Option<CommandLine> {
107        let suffix = if self.identity_source() == AgentIdentitySource::Assigned {
108            Some(format!(
109                "--session-id {}",
110                AgentSession::quote_for_command_shell(session_id.as_ref())?
111            ))
112        } else {
113            None
114        };
115        if suffix.is_some() && !self.command_accepts_provider_arguments(command) {
116            return None;
117        }
118        let command = suffix.map_or_else(
119            || command.as_ref().to_string(),
120            |suffix| format!("{} {suffix}", command.as_ref()),
121        );
122        CommandLine::try_new(command).ok()
123    }
124
125    fn resume_command(
126        &self,
127        command: &CommandLine,
128        native_id: &NativeSessionId,
129    ) -> Option<CommandLine> {
130        if !self.command_accepts_provider_arguments(command) {
131            return None;
132        }
133        let id = AgentSession::quote_for_command_shell(native_id.as_ref())?;
134        let suffix = match self {
135            Self::Claude => format!("--resume {id}"),
136            Self::Codex => format!("resume {id}"),
137            Self::Gemini => format!("--resume {id}"),
138            Self::Amp => format!("threads continue {id}"),
139            Self::Opencode => format!("--session {id}"),
140            Self::Copilot => format!("--resume {id}"),
141            Self::Kimi => format!("--session {id}"),
142            Self::Custom => return None,
143        };
144        CommandLine::try_new(format!("{} {suffix}", command.as_ref())).ok()
145    }
146}
147
148impl AgentTool {
149    /// Whether provider arguments can be appended to this provider's command.
150    fn command_accepts_provider_arguments(&self, command: &CommandLine) -> bool {
151        AgentSession::launch_command_accepts_provider_arguments(command)
152            && Self::command_arguments(command.as_ref()).is_some_and(|arguments| {
153                let executable_index = arguments
154                    .iter()
155                    .position(|argument| !argument.contains('='));
156                let executable = executable_index
157                    .and_then(|index| Self::provider_executable_name(&arguments[index]));
158                executable.is_some_and(|executable| Self::same_provider_command(self, executable))
159                    && executable_index.is_some_and(|index| {
160                        let mut arguments = arguments[index + 1..].iter().peekable();
161                        while let Some(argument) = arguments.next() {
162                            if argument == "--" {
163                                return false;
164                            }
165                            if !argument.starts_with('-') {
166                                return false;
167                            }
168                            if !argument.contains('=')
169                                && arguments
170                                    .peek()
171                                    .is_some_and(|value| !value.starts_with('-'))
172                            {
173                                if self.option_takes_value(argument) {
174                                    arguments.next();
175                                } else {
176                                    return false;
177                                }
178                            }
179                        }
180                        true
181                    })
182            })
183    }
184
185    /// Splits safe provider overrides while preserving an unquoted Windows path.
186    fn command_arguments(command: &str) -> Option<Vec<String>> {
187        let first_argument = command.split_whitespace().next()?;
188        if first_argument.contains('\\') && !first_argument.contains(['\'', '"']) {
189            return Some(command.split_whitespace().map(str::to_string).collect());
190        }
191        shlex::split(command)
192    }
193
194    /// Returns a command path's provider executable name without Windows-only
195    /// path and launcher suffixes.
196    fn provider_executable_name(command: &str) -> Option<&str> {
197        let executable = command.rsplit(['/', '\\']).next()?;
198        Some(
199            executable
200                .strip_suffix(".exe")
201                .or_else(|| executable.strip_suffix(".cmd"))
202                .or_else(|| executable.strip_suffix(".bat"))
203                .unwrap_or(executable),
204        )
205    }
206
207    /// Compares provider executable names using the host shell's semantics.
208    fn same_provider_command(&self, executable: &str) -> bool {
209        self.default_command().is_some_and(|default_command| {
210            #[cfg(windows)]
211            {
212                executable.eq_ignore_ascii_case(default_command)
213            }
214            #[cfg(not(windows))]
215            {
216                executable == default_command
217            }
218        })
219    }
220
221    /// Returns whether `option` consumes no following positional value.
222    fn option_takes_value(self, option: &str) -> bool {
223        match self {
224            Self::Claude => CLAUDE_VALUE_OPTIONS.contains(&option),
225            Self::Codex => CODEX_VALUE_OPTIONS.contains(&option),
226            Self::Gemini => GEMINI_VALUE_OPTIONS.contains(&option),
227            Self::Amp | Self::Opencode | Self::Copilot | Self::Kimi | Self::Custom => false,
228        }
229    }
230}
231
232/// Event names accepted by the public JSON wire protocol.
233#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
234#[serde(rename_all = "snake_case")]
235pub enum AgentProtocolEventKind {
236    /// A provider created or resumed a native conversation.
237    SessionStarted,
238}
239
240/// Canonical versioned event an agent can send to `muster hook capture`.
241#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Getters, TypedBuilder)]
242#[getset(get = "pub")]
243pub struct AgentProtocolEvent {
244    /// Protocol schema version, currently [`AGENT_PROTOCOL_VERSION`].
245    version: u8,
246    /// Lifecycle event represented by this payload.
247    event: AgentProtocolEventKind,
248    /// Provider-owned identity used by its native resume command.
249    session_id: NativeSessionId,
250}
251
252impl AgentProtocolEvent {
253    /// Creates the canonical event for a provider conversation becoming active.
254    pub fn session_started(session_id: NativeSessionId) -> Self {
255        Self::builder()
256            .version(AGENT_PROTOCOL_VERSION)
257            .event(AgentProtocolEventKind::SessionStarted)
258            .session_id(session_id)
259            .build()
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use serde_json::json;
266
267    use super::*;
268
269    /// The canonical constructor fixes the public wire version and event name.
270    #[test]
271    fn session_started_serializes_the_versioned_wire_contract() {
272        let event = AgentProtocolEvent::session_started(
273            NativeSessionId::try_new("provider-session").unwrap(),
274        );
275
276        assert_eq!(
277            serde_json::to_value(event).unwrap(),
278            json!({
279                "version": AGENT_PROTOCOL_VERSION,
280                "event": "session_started",
281                "session_id": "provider-session"
282            })
283        );
284    }
285
286    /// Provider implementations can be consumed behind the public protocol
287    /// trait rather than requiring an enum-specific call site.
288    #[test]
289    fn protocol_is_object_safe() {
290        let protocol: &dyn AgentProtocol = &AgentTool::Codex;
291
292        assert_eq!(protocol.activity_source(), AgentActivitySource::Title);
293    }
294
295    /// Only providers that accept caller-assigned IDs receive a session flag;
296    /// Copilot reports its identity later through its lifecycle hook.
297    #[test]
298    fn new_session_commands_respect_provider_identity_ownership() {
299        let session_id = AgentSessionId::generate().unwrap();
300        let claude = CommandLine::try_new("claude").unwrap();
301        let copilot = CommandLine::try_new("copilot").unwrap();
302
303        assert_eq!(
304            AgentTool::Claude
305                .new_session_command(&claude, &session_id)
306                .unwrap()
307                .as_ref(),
308            format!("claude --session-id {session_id}")
309        );
310        assert_eq!(
311            AgentTool::Copilot
312                .new_session_command(&copilot, &session_id)
313                .unwrap()
314                .as_ref(),
315            "copilot"
316        );
317    }
318
319    /// Composed shell commands require an explicit resume template rather than
320    /// receiving provider flags after an unrelated final command.
321    #[test]
322    fn provider_commands_reject_shell_compositions() {
323        let command = CommandLine::try_new("codex | tee agent.log").unwrap();
324        let wrapper = CommandLine::try_new("bash -lc 'codex'").unwrap();
325        let prompt = CommandLine::try_new("codex 'fix auth'").unwrap();
326        let native_id = NativeSessionId::try_new("thread-id").unwrap();
327
328        assert!(
329            AgentTool::Codex
330                .resume_command(&command, &native_id)
331                .is_none()
332        );
333        assert!(
334            AgentTool::Codex
335                .resume_command(&wrapper, &native_id)
336                .is_none()
337        );
338        assert!(
339            AgentTool::Codex
340                .resume_command(&prompt, &native_id)
341                .is_none()
342        );
343        assert!(
344            AgentTool::Claude
345                .new_session_command(&wrapper, &AgentSessionId::generate().unwrap())
346                .is_none()
347        );
348    }
349
350    /// The end-of-options marker turns every later token positional, so provider
351    /// session controls must never be appended after it.
352    #[test]
353    fn provider_commands_reject_end_of_options_markers() {
354        let command = CommandLine::try_new("claude --").unwrap();
355        let session_id = AgentSessionId::generate().unwrap();
356
357        assert!(
358            AgentTool::Claude
359                .new_session_command(&command, &session_id)
360                .is_none()
361        );
362    }
363
364    /// Windows-style executable paths and launcher suffixes remain valid preset
365    /// overrides even when the host compiling this test is Unix.
366    #[test]
367    fn provider_commands_accept_windows_executable_paths() {
368        let command = CommandLine::try_new(r"C:\Tools\codex.exe --profile work").unwrap();
369        let native_id = NativeSessionId::try_new("thread-id").unwrap();
370
371        assert_eq!(
372            AgentTool::Codex
373                .resume_command(&command, &native_id)
374                .unwrap()
375                .as_ref(),
376            r"C:\Tools\codex.exe --profile work resume thread-id"
377        );
378    }
379
380    /// Preset flags may carry separate values without becoming a shell composition.
381    #[test]
382    fn provider_commands_accept_option_values() {
383        let command = CommandLine::try_new("claude --model opus").unwrap();
384        let session_id = AgentSessionId::generate().unwrap();
385
386        assert_eq!(
387            AgentTool::Claude
388                .new_session_command(&command, &session_id)
389                .unwrap()
390                .as_ref(),
391            format!("claude --model opus --session-id {session_id}")
392        );
393    }
394
395    /// Codex launch-directory and policy overrides consume their following
396    /// values, so a session command remains safe to append.
397    #[test]
398    fn codex_commands_accept_common_value_options() {
399        let command = CommandLine::try_new(
400            "codex -C /repo --sandbox workspace-write --ask-for-approval on-request",
401        )
402        .unwrap();
403        let native_id = NativeSessionId::try_new("thread-id").unwrap();
404
405        assert_eq!(
406            AgentTool::Codex
407                .resume_command(&command, &native_id)
408                .unwrap()
409                .as_ref(),
410            "codex -C /repo --sandbox workspace-write --ask-for-approval on-request resume thread-id"
411        );
412    }
413
414    /// A positional prompt after a boolean option makes provider argument
415    /// placement ambiguous and therefore requires an explicit resume template.
416    #[test]
417    fn provider_commands_reject_prompts_after_boolean_options() {
418        let command = CommandLine::try_new("codex --full-auto 'fix auth'").unwrap();
419        let native_id = NativeSessionId::try_new("thread-id").unwrap();
420
421        assert!(
422            AgentTool::Codex
423                .resume_command(&command, &native_id)
424                .is_none()
425        );
426    }
427
428    /// An unrecognised option cannot safely consume a prompt before appended
429    /// provider arguments.
430    #[test]
431    fn provider_commands_reject_prompts_after_unlisted_options() {
432        let command = CommandLine::try_new("codex --search 'fix bug'").unwrap();
433        let native_id = NativeSessionId::try_new("thread-id").unwrap();
434
435        assert!(
436            AgentTool::Codex
437                .resume_command(&command, &native_id)
438                .is_none()
439        );
440    }
441}