supercode_frontend_model/
capabilities.rs1use std::collections::BTreeSet;
2
3use supercode::FrontendOperationDescriptor;
4use supercode::FrontendOperationInvocation;
5use supercode::FrontendOperationKind;
6use supercode::FrontendRuntimeDescriptor;
7
8#[derive(Clone, Debug, Eq, PartialEq)]
15pub struct ComposerCapabilities {
16 operations: Vec<FrontendOperationDescriptor>,
17 pub can_submit: bool,
18 pub can_steer: bool,
19 pub can_interrupt: bool,
20 pub can_respond: bool,
21 pub can_detach: bool,
22}
23
24impl ComposerCapabilities {
25 pub fn from_descriptor(descriptor: &FrontendRuntimeDescriptor) -> Self {
26 Self {
27 operations: descriptor
28 .operations
29 .iter()
30 .filter(|operation| {
31 operation.kind == FrontendOperationKind::Prompt && operation.command.is_some()
32 })
33 .cloned()
34 .collect(),
35 can_submit: descriptor.actions.submit,
36 can_steer: descriptor.actions.steer,
37 can_interrupt: descriptor.actions.interrupt,
38 can_respond: descriptor.actions.respond,
39 can_detach: descriptor.actions.detach,
40 }
41 }
42
43 pub fn operations(&self) -> &[FrontendOperationDescriptor] {
44 &self.operations
45 }
46
47 pub fn invocation_for_command(&self, input: &str) -> Option<FrontendOperationInvocation> {
48 let trimmed = input.trim_start();
49 let rest = trimmed.strip_prefix('/')?;
50 let (name, arguments) = rest
51 .split_once(char::is_whitespace)
52 .map_or((rest, ""), |(name, arguments)| (name, arguments.trim()));
53 self.operations.iter().find_map(|operation| {
54 if operation
55 .command
56 .as_ref()
57 .map(|command| command.name.as_str())
58 != Some(name)
59 {
60 return None;
61 }
62 match operation.kind {
63 FrontendOperationKind::Prompt => Some(FrontendOperationInvocation::Prompt {
64 operation_id: operation.id.clone(),
65 arguments: arguments.to_string(),
66 }),
67 FrontendOperationKind::Model => Some(FrontendOperationInvocation::Model {
73 operation_id: operation.id.clone(),
74 model: arguments.to_string(),
75 }),
76 FrontendOperationKind::Context => Some(FrontendOperationInvocation::Context {
77 operation_id: operation.id.clone(),
78 }),
79 _ => None,
80 }
81 })
82 }
83
84 pub fn available_actions(&self) -> BTreeSet<String> {
86 let mut actions = self
87 .operations
88 .iter()
89 .map(|operation| format!("operation:{}", operation.id))
90 .collect::<BTreeSet<_>>();
91 if self.can_submit {
92 actions.insert("submit".into());
93 }
94 if self.can_steer {
95 actions.insert("steer".into());
96 }
97 if self.can_interrupt {
98 actions.insert("interrupt".into());
99 }
100 if self.can_respond {
101 actions.insert("respond".into());
102 }
103 if self.can_detach {
104 actions.insert("detach".into());
105 }
106 actions
107 }
108}
109
110#[cfg(test)]
111mod tests {
112 use supercode::FrontendActions;
113 use supercode::FrontendCommandDescriptor;
114 use supercode::FrontendConnectionState;
115 use supercode::FrontendDisplayCapabilities;
116 use supercode::FrontendRuntimeDescriptor;
117 use supercode::FrontendTurnState;
118 use supercode::FRONTEND_RUNTIME_SCHEMA_VERSION;
119
120 use super::*;
121
122 fn descriptor(actions: FrontendActions) -> FrontendRuntimeDescriptor {
123 FrontendRuntimeDescriptor {
124 schema_version: FRONTEND_RUNTIME_SCHEMA_VERSION,
125 session_id: "test-session".into(),
126 source_harness: None,
127 emulation_profile: Some("custom".into()),
128 active_modules: vec![
129 "tools_search".into(),
130 "model_catalog".into(),
131 "session_tree".into(),
132 "subagents".into(),
133 "tui".into(),
134 "reduction".into(),
135 "permissions".into(),
136 "mcp".into(),
137 ],
138 commands: vec![FrontendCommandDescriptor {
139 name: "review".into(),
140 description: None,
141 argument_hint: None,
142 }],
143 operations: vec![FrontendOperationDescriptor {
144 id: "prompt:review".into(),
145 kind: FrontendOperationKind::Prompt,
146 command: Some(FrontendCommandDescriptor {
147 name: "review".into(),
148 description: None,
149 argument_hint: Some("[arguments]".into()),
150 }),
151 }],
152 actions,
153 display: FrontendDisplayCapabilities {
154 event_kinds: vec![],
155 opaque_fallback: true,
156 },
157 model: "test-model".into(),
158 turn_state: FrontendTurnState::Idle,
159 connection_state: FrontendConnectionState::Connected,
160 extensions: Default::default(),
161 }
162 }
163
164 #[test]
165 fn active_modules_do_not_create_frontend_actions() {
166 let mut descriptor = descriptor(FrontendActions {
167 submit: false,
168 interrupt: false,
169 steer: false,
170 respond: false,
171 detach: false,
172 close: true,
173 });
174 descriptor.operations.clear();
175 let capabilities = ComposerCapabilities::from_descriptor(&descriptor);
176
177 assert!(capabilities.available_actions().is_empty());
178 }
179
180 #[test]
181 fn available_actions_are_descriptor_backed() {
182 let capabilities = ComposerCapabilities::from_descriptor(&descriptor(FrontendActions {
183 submit: true,
184 interrupt: true,
185 steer: true,
186 respond: true,
187 detach: true,
188 close: true,
191 }));
192
193 assert_eq!(
194 capabilities.available_actions(),
195 BTreeSet::from([
196 "operation:prompt:review".to_string(),
197 "detach".to_string(),
198 "interrupt".to_string(),
199 "respond".to_string(),
200 "steer".to_string(),
201 "submit".to_string(),
202 ])
203 );
204 }
205
206 #[test]
207 fn schema_v1_descriptor_never_falls_back_to_legacy_commands_or_modules() {
208 let value = serde_json::json!({
209 "schema_version": 1,
210 "session_id": "old-runtime",
211 "source_harness": null,
212 "emulation_profile": "cc-parity",
213 "active_modules": ["model_catalog", "session_tree", "subagents", "reduction"],
214 "commands": [{"name":"legacy-only", "description":null}],
215 "actions": {
216 "submit": true, "interrupt": true, "steer": true,
217 "respond": false, "detach": true, "close": false
218 },
219 "display": {"event_kinds":[], "opaque_fallback":true},
220 "model": "test",
221 "turn_state": "idle",
222 "connection_state": "connected"
223 });
224 let descriptor: FrontendRuntimeDescriptor = serde_json::from_value(value).unwrap();
225 assert!(descriptor.operations.is_empty());
226 assert!(descriptor.commands[0].argument_hint.is_none());
227 let capabilities = ComposerCapabilities::from_descriptor(&descriptor);
228 assert_eq!(
229 capabilities.available_actions(),
230 BTreeSet::from([
231 "detach".to_string(),
232 "interrupt".to_string(),
233 "steer".to_string(),
234 "submit".to_string(),
235 ])
236 );
237 assert!(capabilities.operations().is_empty());
238 }
239}