Skip to main content

supercode_frontend_model/
capabilities.rs

1use std::collections::BTreeSet;
2
3use supercode::FrontendOperationDescriptor;
4use supercode::FrontendOperationInvocation;
5use supercode::FrontendOperationKind;
6use supercode::FrontendRuntimeDescriptor;
7
8/// Composer controls copied from the SDK runtime descriptor.
9///
10/// `active_modules` describes the agent semantics behind the runtime. It is
11/// intentionally not interpreted as a frontend operation registry: a search
12/// tool, session tree, or reduction policy does not by itself provide the
13/// terminal with a file picker, session switcher, or reduction RPC.
14#[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    /// Stable identifiers for operations with real composer/runtime routes.
68    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            // No composer/runtime close route exists, so even a malformed
172            // descriptor cannot turn this into an advertised TUI action.
173            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}