supercode-frontend-model 0.4.2

Donor-neutral client state and semantic intents for Supercode frontends.
Documentation
use std::collections::BTreeSet;

use supercode::FrontendOperationDescriptor;
use supercode::FrontendOperationInvocation;
use supercode::FrontendOperationKind;
use supercode::FrontendRuntimeDescriptor;

/// Composer controls copied from the SDK runtime descriptor.
///
/// `active_modules` describes the agent semantics behind the runtime. It is
/// intentionally not interpreted as a frontend operation registry: a search
/// tool, session tree, or reduction policy does not by itself provide the
/// terminal with a file picker, session switcher, or reduction RPC.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ComposerCapabilities {
    operations: Vec<FrontendOperationDescriptor>,
    pub can_submit: bool,
    pub can_steer: bool,
    pub can_interrupt: bool,
    pub can_respond: bool,
    pub can_detach: bool,
}

impl ComposerCapabilities {
    pub fn from_descriptor(descriptor: &FrontendRuntimeDescriptor) -> Self {
        Self {
            operations: descriptor
                .operations
                .iter()
                .filter(|operation| {
                    operation.kind == FrontendOperationKind::Prompt && operation.command.is_some()
                })
                .cloned()
                .collect(),
            can_submit: descriptor.actions.submit,
            can_steer: descriptor.actions.steer,
            can_interrupt: descriptor.actions.interrupt,
            can_respond: descriptor.actions.respond,
            can_detach: descriptor.actions.detach,
        }
    }

    pub fn operations(&self) -> &[FrontendOperationDescriptor] {
        &self.operations
    }

    pub fn invocation_for_command(&self, input: &str) -> Option<FrontendOperationInvocation> {
        let trimmed = input.trim_start();
        let rest = trimmed.strip_prefix('/')?;
        let (name, arguments) = rest
            .split_once(char::is_whitespace)
            .map_or((rest, ""), |(name, arguments)| (name, arguments.trim()));
        self.operations.iter().find_map(|operation| {
            (operation.kind == FrontendOperationKind::Prompt
                && operation
                    .command
                    .as_ref()
                    .map(|command| command.name.as_str())
                    == Some(name))
            .then(|| FrontendOperationInvocation::Prompt {
                operation_id: operation.id.clone(),
                arguments: arguments.to_string(),
            })
        })
    }

    /// Stable identifiers for operations with real composer/runtime routes.
    pub fn available_actions(&self) -> BTreeSet<String> {
        let mut actions = self
            .operations
            .iter()
            .map(|operation| format!("operation:{}", operation.id))
            .collect::<BTreeSet<_>>();
        if self.can_submit {
            actions.insert("submit".into());
        }
        if self.can_steer {
            actions.insert("steer".into());
        }
        if self.can_interrupt {
            actions.insert("interrupt".into());
        }
        if self.can_respond {
            actions.insert("respond".into());
        }
        if self.can_detach {
            actions.insert("detach".into());
        }
        actions
    }
}

#[cfg(test)]
mod tests {
    use supercode::FrontendActions;
    use supercode::FrontendCommandDescriptor;
    use supercode::FrontendConnectionState;
    use supercode::FrontendDisplayCapabilities;
    use supercode::FrontendRuntimeDescriptor;
    use supercode::FrontendTurnState;
    use supercode::FRONTEND_RUNTIME_SCHEMA_VERSION;

    use super::*;

    fn descriptor(actions: FrontendActions) -> FrontendRuntimeDescriptor {
        FrontendRuntimeDescriptor {
            schema_version: FRONTEND_RUNTIME_SCHEMA_VERSION,
            session_id: "test-session".into(),
            source_harness: None,
            emulation_profile: Some("custom".into()),
            active_modules: vec![
                "tools_search".into(),
                "model_catalog".into(),
                "session_tree".into(),
                "subagents".into(),
                "tui".into(),
                "reduction".into(),
                "permissions".into(),
                "mcp".into(),
            ],
            commands: vec![FrontendCommandDescriptor {
                name: "review".into(),
                description: None,
                argument_hint: None,
            }],
            operations: vec![FrontendOperationDescriptor {
                id: "prompt:review".into(),
                kind: FrontendOperationKind::Prompt,
                command: Some(FrontendCommandDescriptor {
                    name: "review".into(),
                    description: None,
                    argument_hint: Some("[arguments]".into()),
                }),
            }],
            actions,
            display: FrontendDisplayCapabilities {
                event_kinds: vec![],
                opaque_fallback: true,
            },
            model: "test-model".into(),
            turn_state: FrontendTurnState::Idle,
            connection_state: FrontendConnectionState::Connected,
            extensions: Default::default(),
        }
    }

    #[test]
    fn active_modules_do_not_create_frontend_actions() {
        let mut descriptor = descriptor(FrontendActions {
            submit: false,
            interrupt: false,
            steer: false,
            respond: false,
            detach: false,
            close: true,
        });
        descriptor.operations.clear();
        let capabilities = ComposerCapabilities::from_descriptor(&descriptor);

        assert!(capabilities.available_actions().is_empty());
    }

    #[test]
    fn available_actions_are_descriptor_backed() {
        let capabilities = ComposerCapabilities::from_descriptor(&descriptor(FrontendActions {
            submit: true,
            interrupt: true,
            steer: true,
            respond: true,
            detach: true,
            // No composer/runtime close route exists, so even a malformed
            // descriptor cannot turn this into an advertised TUI action.
            close: true,
        }));

        assert_eq!(
            capabilities.available_actions(),
            BTreeSet::from([
                "operation:prompt:review".to_string(),
                "detach".to_string(),
                "interrupt".to_string(),
                "respond".to_string(),
                "steer".to_string(),
                "submit".to_string(),
            ])
        );
    }

    #[test]
    fn schema_v1_descriptor_never_falls_back_to_legacy_commands_or_modules() {
        let value = serde_json::json!({
            "schema_version": 1,
            "session_id": "old-runtime",
            "source_harness": null,
            "emulation_profile": "cc-parity",
            "active_modules": ["model_catalog", "session_tree", "subagents", "reduction"],
            "commands": [{"name":"legacy-only", "description":null}],
            "actions": {
                "submit": true, "interrupt": true, "steer": true,
                "respond": false, "detach": true, "close": false
            },
            "display": {"event_kinds":[], "opaque_fallback":true},
            "model": "test",
            "turn_state": "idle",
            "connection_state": "connected"
        });
        let descriptor: FrontendRuntimeDescriptor = serde_json::from_value(value).unwrap();
        assert!(descriptor.operations.is_empty());
        assert!(descriptor.commands[0].argument_hint.is_none());
        let capabilities = ComposerCapabilities::from_descriptor(&descriptor);
        assert_eq!(
            capabilities.available_actions(),
            BTreeSet::from([
                "detach".to_string(),
                "interrupt".to_string(),
                "steer".to_string(),
                "submit".to_string(),
            ])
        );
        assert!(capabilities.operations().is_empty());
    }
}