1use 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#[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 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}