Skip to main content

ai_session_search/
skill_run.rs

1// SPDX-FileCopyrightText: 2026 Andrew Hundt
2// SPDX-License-Identifier: Apache-2.0
3
4//! Typed deterministic capability requests and reports.
5//!
6//! This is the single wire contract shared by Rust and the CLI, and later by the thin Python and
7//! MCP adapters. Skill discovery, descriptor validation, and capability compilation remain in
8//! their domain modules; this module only defines the closed request/output vocabulary.
9
10use std::path::PathBuf;
11
12use serde::{Deserialize, Serialize};
13
14use crate::corrections::CorrectionCategorySpec;
15use crate::corrections::{CapabilityReceipt, MessageClassificationReport};
16use crate::models::MessageFilters;
17use crate::skill_catalog::{SkillName, SkillSelector};
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20#[serde(deny_unknown_fields)]
21pub struct SkillRunQuery {
22    pub skill: SkillSelector,
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub definition: Option<MessageClassificationDefinition>,
25    pub input: SkillCapabilityInput,
26}
27
28/// Direct executable message-classification rules for the selected skill.
29///
30/// The selected skill continues to own name, version, instructions, authorization, and result
31/// identity. Supplying this value replaces only its adjacent `aise-capability.toml` rules for one run.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(deny_unknown_fields)]
34pub struct MessageClassificationDefinition {
35    pub categories: Vec<CorrectionCategorySpec>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39#[serde(
40    tag = "capability",
41    content = "arguments",
42    rename_all = "kebab-case",
43    deny_unknown_fields
44)]
45pub enum SkillCapabilityInput {
46    MessageClassification(MessageClassificationQuery),
47}
48
49#[derive(Debug, Clone, Default, Serialize, Deserialize)]
50#[serde(default, deny_unknown_fields)]
51pub struct MessageClassificationQuery {
52    pub filters: MessageFilters,
53    pub additional_skills: Vec<SkillSelector>,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
57#[serde(deny_unknown_fields)]
58pub struct SkillRunReport {
59    pub requested_selector: SkillSelector,
60    pub resolved_skill: ResolvedSkillReceipt,
61    pub output: SkillCapabilityOutput,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
65#[serde(
66    tag = "capability",
67    content = "result",
68    rename_all = "kebab-case",
69    deny_unknown_fields
70)]
71pub enum SkillCapabilityOutput {
72    MessageClassification(MessageClassificationResult),
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
76#[serde(deny_unknown_fields)]
77pub struct MessageClassificationResult {
78    /// Receipt for the primary selected skill's compiled classification policy.
79    ///
80    /// This is derived from, and byte-for-byte equal to, the first entry in
81    /// `report.policies`. The explicit primary receipt keeps the generic capability envelope
82    /// self-describing; `report.policies` additionally records every ordered `--skill` policy.
83    pub receipt: CapabilityReceipt,
84    pub report: MessageClassificationReport,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
88#[serde(deny_unknown_fields)]
89pub struct ResolvedSkillReceipt {
90    pub name: SkillName,
91    pub package_version: Option<String>,
92    pub selected_location: SelectedSkillLocation,
93    pub execution_source: CapabilityExecutionSource,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
97#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
98pub enum SelectedSkillLocation {
99    Embedded,
100    Path { canonical_skill_md: PathBuf },
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
104#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
105pub enum CapabilityExecutionSource {
106    Embedded,
107    Path { canonical_capability_toml: PathBuf },
108    Inline,
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use crate::corrections::CapabilityReceipt;
115    use crate::models::{MessageClassificationMatch, Provider};
116    use crate::skill_catalog::{SkillNameSelector, SkillPathSelector};
117
118    #[test]
119    fn request_wire_shape_is_tagged_and_rejects_ambiguous_or_duplicate_fields() {
120        let query: SkillRunQuery = serde_json::from_value(serde_json::json!({
121            "skill": {"name": "corrections"},
122            "definition": {
123                "categories": [{
124                    "name": "accuracy",
125                    "patterns": ["\\\\bwrong\\\\b"]
126                }]
127            },
128            "input": {
129                "capability": "message-classification",
130                "arguments": {
131                    "filters": {},
132                    "additional_skills": [
133                        {"path": "./my-review"}
134                    ]
135                }
136            }
137        }))
138        .unwrap();
139        assert!(matches!(
140            query.skill,
141            SkillSelector::Name(SkillNameSelector { .. })
142        ));
143        assert_eq!(query.definition.unwrap().categories[0].name, "accuracy");
144        let SkillCapabilityInput::MessageClassification(input) = query.input;
145        assert!(matches!(
146            input.additional_skills.as_slice(),
147            [SkillSelector::Path(SkillPathSelector { .. })]
148        ));
149
150        for invalid in [
151            serde_json::json!({
152                "skill": {"name": "corrections", "path": "./corrections"},
153                "input": {
154                    "capability": "message-classification",
155                    "arguments": {}
156                }
157            }),
158            serde_json::json!({
159                "skill": {"name": "corrections"},
160                "additional_skills": [],
161                "input": {
162                    "capability": "message-classification",
163                    "arguments": {}
164                }
165            }),
166        ] {
167            assert!(
168                serde_json::from_value::<SkillRunQuery>(invalid).is_err(),
169                "ambiguous selectors and root-level additional_skills must fail"
170            );
171        }
172    }
173
174    #[test]
175    fn generalized_result_types_preserve_the_message_classification_wire_shape() {
176        let result = MessageClassificationResult {
177            receipt: CapabilityReceipt {
178                name: "corrections".to_string(),
179                version: "1.0.0".to_string(),
180                sha256: "digest".to_string(),
181            },
182            report: MessageClassificationReport {
183                policies: vec![CapabilityReceipt {
184                    name: "corrections".to_string(),
185                    version: "1.0.0".to_string(),
186                    sha256: "digest".to_string(),
187                }],
188                matches: vec![MessageClassificationMatch {
189                    session_id: "claude:session".to_string(),
190                    message_seq: 4,
191                    provider: Provider::Claude,
192                    ts: None,
193                    policy_name: "corrections".to_string(),
194                    category: "accuracy".to_string(),
195                    matched_text: "actually".to_string(),
196                    match_start_char: 0,
197                    match_end_char_exclusive: 8,
198                    content: "Actually, use the other command.".to_string(),
199                }],
200            },
201        };
202
203        assert_eq!(
204            serde_json::to_value(result).unwrap(),
205            serde_json::json!({
206                "receipt": {
207                    "name": "corrections",
208                    "version": "1.0.0",
209                    "sha256": "digest"
210                },
211                "report": {
212                    "policies": [{
213                        "name": "corrections",
214                        "version": "1.0.0",
215                        "sha256": "digest"
216                    }],
217                    "matches": [{
218                        "session_id": "claude:session",
219                        "message_seq": 4,
220                        "provider": "claude",
221                        "ts": null,
222                        "policy_name": "corrections",
223                        "category": "accuracy",
224                        "matched_text": "actually",
225                        "match_start_char": 0,
226                        "match_end_char_exclusive": 8,
227                        "content": "Actually, use the other command."
228                    }]
229                }
230            })
231        );
232    }
233}