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            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                // BP-13 (catalog D9 "Mid-session model switching"): the
68                // model control is reachable by TYPING `/model <name>` on
69                // whatever composer this runtime is attached to — the
70                // surface both parity presets actually run on, not only the
71                // line-mode REPL.
72                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    /// Stable identifiers for operations with real composer/runtime routes.
85    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            // No composer/runtime close route exists, so even a malformed
189            // descriptor cannot turn this into an advertised TUI action.
190            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}