gate4agent_adapters/
capability.rs1use gate4agent_types::{AdapterId, CapabilityModelSummary, CAPABILITY_MODELS_MAX};
2use std::collections::BTreeSet;
3use thiserror::Error;
4
5pub const CAPABILITY_PROBE_REVISION: &str = "gate4agent-capability-probe/orca-d8629c4/v1";
6pub const CAPABILITY_PROBE_OUTPUT_MAX_BYTES: usize = 4 * 1024 * 1024;
7
8#[derive(Clone, Debug, Eq, PartialEq)]
9pub struct CapabilityProbePlan {
10 pub program: String,
11 pub args: Vec<String>,
12}
13
14pub fn capability_probe_plan(
15 adapter_id: &AdapterId,
16) -> Result<CapabilityProbePlan, CapabilityProbeAdapterError> {
17 match adapter_id.as_str() {
18 "cursor" => Ok(CapabilityProbePlan {
21 program: "cursor-agent".to_owned(),
22 args: vec!["--list-models".to_owned()],
23 }),
24 id => Err(CapabilityProbeAdapterError::UnsupportedAdapter(
25 id.to_owned(),
26 )),
27 }
28}
29
30pub fn parse_capability_models(
31 adapter_id: &AdapterId,
32 stdout: &str,
33) -> Result<Vec<CapabilityModelSummary>, CapabilityProbeAdapterError> {
34 capability_probe_plan(adapter_id)?;
35 if stdout.len() > CAPABILITY_PROBE_OUTPUT_MAX_BYTES {
36 return Err(CapabilityProbeAdapterError::OutputTooLarge);
37 }
38
39 let mut seen = BTreeSet::new();
40 let mut models = Vec::new();
41 for raw_line in stdout.lines() {
42 let line = raw_line.trim();
43 let Some(separator) = model_separator(line) else {
44 continue;
45 };
46 let id = line[..separator].trim();
47 let label = strip_cursor_status_suffix(line[separator + 1..].trim());
48 let model = CapabilityModelSummary {
49 id: id.to_owned(),
50 label: label.to_owned(),
51 };
52 if model.validate().is_err() || !seen.insert(model.id.clone()) {
53 continue;
54 }
55 models.push(model);
56 if models.len() == CAPABILITY_MODELS_MAX {
57 break;
58 }
59 }
60 Ok(models)
61}
62
63fn model_separator(line: &str) -> Option<usize> {
64 line.char_indices().find_map(|(index, character)| {
65 if character != '-' {
66 return None;
67 }
68 let before = line[..index].chars().next_back()?;
69 let after = line[index + 1..].chars().next()?;
70 (before.is_whitespace() && after.is_whitespace()).then_some(index)
71 })
72}
73
74fn strip_cursor_status_suffix(label: &str) -> &str {
75 for suffix in ["(default)", "(current)"] {
76 if label.len() >= suffix.len()
77 && label[label.len() - suffix.len()..].eq_ignore_ascii_case(suffix)
78 {
79 let prefix = &label[..label.len() - suffix.len()];
80 if prefix.chars().next_back().is_some_and(char::is_whitespace) {
81 return prefix.trim_end();
82 }
83 }
84 }
85 label
86}
87
88#[derive(Clone, Debug, Error, Eq, PartialEq)]
89pub enum CapabilityProbeAdapterError {
90 #[error("capability probe adapter is unavailable for {0}")]
91 UnsupportedAdapter(String),
92 #[error("capability probe output exceeds its byte bound")]
93 OutputTooLarge,
94}
95
96#[cfg(test)]
97mod tests {
98 use super::*;
99
100 fn cursor() -> AdapterId {
101 AdapterId::new("cursor").unwrap()
102 }
103
104 #[test]
105 fn cursor_uses_the_actually_executed_orca_probe_contract() {
106 assert_eq!(
107 capability_probe_plan(&cursor()).unwrap(),
108 CapabilityProbePlan {
109 program: "cursor-agent".to_owned(),
110 args: vec!["--list-models".to_owned()],
111 }
112 );
113 }
114
115 #[test]
116 fn cursor_models_preserve_live_labels_and_drop_status_suffixes() {
117 let models = parse_capability_models(
118 &cursor(),
119 "noise\nauto - Auto (default)\ngpt-5.3-codex - GPT-5.3 Codex\ntab-model - Tab model\t(current)\nauto - Duplicate\n",
120 )
121 .unwrap();
122 assert_eq!(
123 models,
124 vec![
125 CapabilityModelSummary {
126 id: "auto".to_owned(),
127 label: "Auto".to_owned(),
128 },
129 CapabilityModelSummary {
130 id: "gpt-5.3-codex".to_owned(),
131 label: "GPT-5.3 Codex".to_owned(),
132 },
133 CapabilityModelSummary {
134 id: "tab-model".to_owned(),
135 label: "Tab model".to_owned(),
136 },
137 ]
138 );
139 }
140}