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
11pub const AGENT_PROTOCOL_VERSION: u8 = 1;
13const CLAUDE_VALUE_OPTIONS: &[&str] = &[
15 "--model",
16 "--add-dir",
17 "--settings",
18 "--permission-mode",
19 "--fallback-model",
20];
21const 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];
33const GEMINI_VALUE_OPTIONS: &[&str] = &["--model"];
35
36#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
38pub enum AgentActivitySource {
39 #[default]
41 Output,
42 Title,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum AgentIdentitySource {
49 Assigned,
51 Reported,
53}
54
55pub(crate) trait AgentProtocol {
58 fn activity_source(&self) -> AgentActivitySource;
60
61 fn identity_source(&self) -> AgentIdentitySource;
63
64 fn new_session_command(
66 &self,
67 command: &CommandLine,
68 session_id: &AgentSessionId,
69 ) -> Option<CommandLine>;
70
71 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 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 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 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
234#[serde(rename_all = "snake_case")]
235pub enum AgentProtocolEventKind {
236 SessionStarted,
238}
239
240#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Getters, TypedBuilder)]
242#[getset(get = "pub")]
243pub struct AgentProtocolEvent {
244 version: u8,
246 event: AgentProtocolEventKind,
248 session_id: NativeSessionId,
250}
251
252impl AgentProtocolEvent {
253 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}