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 (operation.kind == FrontendOperationKind::Prompt
55 && operation
56 .command
57 .as_ref()
58 .map(|command| command.name.as_str())
59 == Some(name))
60 .then(|| FrontendOperationInvocation::Prompt {
61 operation_id: operation.id.clone(),
62 arguments: arguments.to_string(),
63 })
64 })
65 }
66
67 pub fn available_actions(&self) -> BTreeSet<String> {
69 let mut actions = self
70 .operations
71 .iter()
72 .map(|operation| format!("operation:{}", operation.id))
73 .collect::<BTreeSet<_>>();
74 if self.can_submit {
75 actions.insert("submit".into());
76 }
77 if self.can_steer {
78 actions.insert("steer".into());
79 }
80 if self.can_interrupt {
81 actions.insert("interrupt".into());
82 }
83 if self.can_respond {
84 actions.insert("respond".into());
85 }
86 if self.can_detach {
87 actions.insert("detach".into());
88 }
89 actions
90 }
91}
92
93#[cfg(test)]
94mod tests {
95 use supercode::FrontendActions;
96 use supercode::FrontendCommandDescriptor;
97 use supercode::FrontendConnectionState;
98 use supercode::FrontendDisplayCapabilities;
99 use supercode::FrontendRuntimeDescriptor;
100 use supercode::FrontendTurnState;
101 use supercode::FRONTEND_RUNTIME_SCHEMA_VERSION;
102
103 use super::*;
104
105 fn descriptor(actions: FrontendActions) -> FrontendRuntimeDescriptor {
106 FrontendRuntimeDescriptor {
107 schema_version: FRONTEND_RUNTIME_SCHEMA_VERSION,
108 session_id: "test-session".into(),
109 source_harness: None,
110 emulation_profile: Some("custom".into()),
111 active_modules: vec![
112 "tools_search".into(),
113 "model_catalog".into(),
114 "session_tree".into(),
115 "subagents".into(),
116 "tui".into(),
117 "reduction".into(),
118 "permissions".into(),
119 "mcp".into(),
120 ],
121 commands: vec![FrontendCommandDescriptor {
122 name: "review".into(),
123 description: None,
124 argument_hint: None,
125 }],
126 operations: vec![FrontendOperationDescriptor {
127 id: "prompt:review".into(),
128 kind: FrontendOperationKind::Prompt,
129 command: Some(FrontendCommandDescriptor {
130 name: "review".into(),
131 description: None,
132 argument_hint: Some("[arguments]".into()),
133 }),
134 }],
135 actions,
136 display: FrontendDisplayCapabilities {
137 event_kinds: vec![],
138 opaque_fallback: true,
139 },
140 model: "test-model".into(),
141 turn_state: FrontendTurnState::Idle,
142 connection_state: FrontendConnectionState::Connected,
143 extensions: Default::default(),
144 }
145 }
146
147 #[test]
148 fn active_modules_do_not_create_frontend_actions() {
149 let mut descriptor = descriptor(FrontendActions {
150 submit: false,
151 interrupt: false,
152 steer: false,
153 respond: false,
154 detach: false,
155 close: true,
156 });
157 descriptor.operations.clear();
158 let capabilities = ComposerCapabilities::from_descriptor(&descriptor);
159
160 assert!(capabilities.available_actions().is_empty());
161 }
162
163 #[test]
164 fn available_actions_are_descriptor_backed() {
165 let capabilities = ComposerCapabilities::from_descriptor(&descriptor(FrontendActions {
166 submit: true,
167 interrupt: true,
168 steer: true,
169 respond: true,
170 detach: true,
171 close: true,
174 }));
175
176 assert_eq!(
177 capabilities.available_actions(),
178 BTreeSet::from([
179 "operation:prompt:review".to_string(),
180 "detach".to_string(),
181 "interrupt".to_string(),
182 "respond".to_string(),
183 "steer".to_string(),
184 "submit".to_string(),
185 ])
186 );
187 }
188
189 #[test]
190 fn schema_v1_descriptor_never_falls_back_to_legacy_commands_or_modules() {
191 let value = serde_json::json!({
192 "schema_version": 1,
193 "session_id": "old-runtime",
194 "source_harness": null,
195 "emulation_profile": "cc-parity",
196 "active_modules": ["model_catalog", "session_tree", "subagents", "reduction"],
197 "commands": [{"name":"legacy-only", "description":null}],
198 "actions": {
199 "submit": true, "interrupt": true, "steer": true,
200 "respond": false, "detach": true, "close": false
201 },
202 "display": {"event_kinds":[], "opaque_fallback":true},
203 "model": "test",
204 "turn_state": "idle",
205 "connection_state": "connected"
206 });
207 let descriptor: FrontendRuntimeDescriptor = serde_json::from_value(value).unwrap();
208 assert!(descriptor.operations.is_empty());
209 assert!(descriptor.commands[0].argument_hint.is_none());
210 let capabilities = ComposerCapabilities::from_descriptor(&descriptor);
211 assert_eq!(
212 capabilities.available_actions(),
213 BTreeSet::from([
214 "detach".to_string(),
215 "interrupt".to_string(),
216 "steer".to_string(),
217 "submit".to_string(),
218 ])
219 );
220 assert!(capabilities.operations().is_empty());
221 }
222}