1#![allow(missing_docs)]
6use agent_base::ToolMetadata as AgentToolMetadata;
30use serde::{Deserialize, Serialize};
31use serde_json::Value;
32
33pub const PROTOCOL_VERSION: u32 = 1;
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct ToolMetadata {
42 pub name: String,
43 pub description: String,
44 pub origin: String,
45 pub version: String,
46 pub requirements: Vec<String>,
47}
48
49impl From<AgentToolMetadata> for ToolMetadata {
50 fn from(m: AgentToolMetadata) -> Self {
51 Self {
52 name: m.name,
53 description: m.description,
54 origin: m.origin,
55 version: m.version,
56 requirements: m.requirements,
57 }
58 }
59}
60
61#[derive(Debug, Deserialize)]
64#[serde(tag = "type", rename_all = "snake_case")]
65pub enum IncomingMessage {
66 RegisterTool {
67 name: String,
68 description: String,
69 parameters: Value,
70 },
71 CreateSession {
72 #[serde(default)]
73 session_id: Option<String>,
74 },
75 Run {
76 #[serde(default)]
77 session_id: String,
78 query: String,
79 #[serde(default)]
80 config: Option<RunConfig>,
81 },
82 ToolResult {
83 call_id: String,
84 summary: String,
85 #[serde(default)]
86 raw: Option<Value>,
87 #[serde(default)]
88 control_flow: Option<String>,
89 },
90 Cancel {
91 #[serde(default)]
92 session_id: String,
93 },
94 ListTools {},
95}
96
97#[derive(Debug, Deserialize, Default)]
98pub struct RunConfig {
99 #[serde(default)]
100 pub model: Option<String>,
101 #[serde(default)]
102 pub api_key: Option<String>,
103 #[serde(default)]
104 pub base_url: Option<String>,
105 pub enable_thinking: Option<bool>,
106 pub thinking_budget: Option<u64>,
107 pub thinking_effort: Option<String>,
108 pub max_tool_calls_per_turn: Option<usize>,
109 pub max_consecutive_failures: Option<usize>,
110 pub max_turns: Option<u32>,
111}
112
113#[derive(Debug, Serialize)]
116#[serde(tag = "type", rename_all = "snake_case")]
117pub enum OutgoingMessage {
118 Hello {
119 protocol_version: u32,
120 server_name: String,
121 server_version: String,
122 },
123 SessionCreated {
124 session_id: Option<String>,
125 internal_id: u64,
126 },
127 Event {
128 seq: u64,
129 #[serde(flatten)]
130 event: Value,
131 },
132 ToolCall {
133 seq: u64,
134 call_id: String,
135 name: String,
136 args: Value,
137 },
138 ToolRegistered {
139 name: String,
140 ok: bool,
141 },
142 Done {
143 seq: u64,
144 outcome: String,
145 #[serde(skip_serializing_if = "Option::is_none")]
146 error: Option<String>,
147 #[serde(skip_serializing_if = "Option::is_none")]
148 turns: Option<u32>,
149 },
150 Error {
151 code: String,
152 message: String,
153 #[serde(skip_serializing_if = "Option::is_none")]
154 detail: Option<Value>,
155 },
156 ToolsListed {
157 tools: Vec<ToolMetadata>,
158 },
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164 use agent_base::ToolMetadata as AgentToolMetadata;
165
166 #[test]
169 fn test_deserialize_all_incoming_variants() {
170 let json = r#"{"type":"register_tool","name":"shell","description":"run shell","parameters":{}}"#;
172 let msg: IncomingMessage = serde_json::from_str(json).unwrap();
173 assert!(matches!(msg, IncomingMessage::RegisterTool { .. }));
174
175 let json = r#"{"type":"create_session","session_id":"ext-1"}"#;
177 let msg: IncomingMessage = serde_json::from_str(json).unwrap();
178 assert!(matches!(msg, IncomingMessage::CreateSession { .. }));
179
180 let json = r#"{"type":"create_session"}"#;
182 let msg: IncomingMessage = serde_json::from_str(json).unwrap();
183 assert!(matches!(msg, IncomingMessage::CreateSession { session_id: None }));
184
185 let json = r#"{"type":"run","session_id":"abc","query":"hello"}"#;
187 let msg: IncomingMessage = serde_json::from_str(json).unwrap();
188 assert!(matches!(msg, IncomingMessage::Run { .. }));
189
190 let json = r#"{"type":"tool_result","call_id":"c1","summary":"done"}"#;
192 let msg: IncomingMessage = serde_json::from_str(json).unwrap();
193 assert!(matches!(msg, IncomingMessage::ToolResult { .. }));
194
195 let json = r#"{"type":"cancel","session_id":"abc"}"#;
197 let msg: IncomingMessage = serde_json::from_str(json).unwrap();
198 assert!(matches!(msg, IncomingMessage::Cancel { .. }));
199
200 let json = r#"{"type":"list_tools"}"#;
202 let msg: IncomingMessage = serde_json::from_str(json).unwrap();
203 assert!(matches!(msg, IncomingMessage::ListTools {}));
204 }
205
206 #[test]
207 fn test_run_config_all_fields_populated() {
208 let json = r#"{
209 "type":"run",
210 "session_id":"s1",
211 "query":"test",
212 "config":{
213 "model":"gpt-4",
214 "api_key":"sk-xxx",
215 "base_url":"https://example.com/v1",
216 "enable_thinking":true,
217 "thinking_budget":32000,
218 "thinking_effort":"high",
219 "max_tool_calls_per_turn":10,
220 "max_consecutive_failures":3,
221 "max_turns":5
222 }
223 }"#;
224 let msg: IncomingMessage = serde_json::from_str(json).unwrap();
225 if let IncomingMessage::Run { config: Some(cfg), .. } = &msg {
226 assert_eq!(cfg.model.as_deref(), Some("gpt-4"));
227 assert_eq!(cfg.api_key.as_deref(), Some("sk-xxx"));
228 assert_eq!(cfg.base_url.as_deref(), Some("https://example.com/v1"));
229 assert_eq!(cfg.enable_thinking, Some(true));
230 assert_eq!(cfg.thinking_budget, Some(32000));
231 assert_eq!(cfg.thinking_effort.as_deref(), Some("high"));
232 assert_eq!(cfg.max_tool_calls_per_turn, Some(10));
233 assert_eq!(cfg.max_consecutive_failures, Some(3));
234 assert_eq!(cfg.max_turns, Some(5));
235 } else {
236 panic!("expected Run with config");
237 }
238 }
239
240 #[test]
241 fn test_run_config_default_all_none() {
242 let cfg: RunConfig = serde_json::from_str("{}").unwrap();
243 assert!(cfg.model.is_none());
244 assert!(cfg.api_key.is_none());
245 assert!(cfg.base_url.is_none());
246 assert!(cfg.enable_thinking.is_none());
247 assert!(cfg.thinking_budget.is_none());
248 assert!(cfg.thinking_effort.is_none());
249 assert!(cfg.max_tool_calls_per_turn.is_none());
250 assert!(cfg.max_consecutive_failures.is_none());
251 assert!(cfg.max_turns.is_none());
252 }
253
254 #[test]
255 fn test_run_without_config_uses_none() {
256 let json = r#"{"type":"run","session_id":"s1","query":"test"}"#;
257 let msg: IncomingMessage = serde_json::from_str(json).unwrap();
258 if let IncomingMessage::Run { config, .. } = &msg {
259 assert!(config.is_none());
260 } else {
261 panic!("expected Run");
262 }
263 }
264
265 #[test]
266 fn test_unknown_fields_ignored() {
267 let json = r#"{"type":"list_tools","extra_field":"should-be-ignored","nested":{"a":1}}"#;
268 let msg: IncomingMessage = serde_json::from_str(json).unwrap();
269 assert!(matches!(msg, IncomingMessage::ListTools {}));
270 }
271
272 #[test]
273 fn test_missing_type_field_errors() {
274 let json = r#"{"session_id":"abc","query":"hello"}"#;
275 let result = serde_json::from_str::<IncomingMessage>(json);
276 assert!(result.is_err());
277 }
278
279 #[test]
280 fn test_deserialize_tool_result_with_all_fields() {
281 let json =
282 r#"{"type":"tool_result","call_id":"c1","summary":"done","raw":{"output":"hello"},"control_flow":"break"}"#;
283 let msg: IncomingMessage = serde_json::from_str(json).unwrap();
284 if let IncomingMessage::ToolResult { call_id, summary, raw, control_flow } = &msg {
285 assert_eq!(call_id, "c1");
286 assert_eq!(summary, "done");
287 assert_eq!(raw.as_ref().and_then(|v| v.get("output")).and_then(|v| v.as_str()), Some("hello"));
288 assert_eq!(control_flow.as_deref(), Some("break"));
289 } else {
290 panic!("expected ToolResult");
291 }
292 }
293
294 #[test]
297 fn test_serialize_all_outgoing_variants() {
298 let msg =
300 OutgoingMessage::Hello { protocol_version: 1, server_name: "phi".into(), server_version: "0.2.6".into() };
301 let json = serde_json::to_value(&msg).unwrap();
302 assert_eq!(json["type"], "hello");
303 assert_eq!(json["protocol_version"], 1);
304
305 let msg = OutgoingMessage::SessionCreated { session_id: Some("ext-1".into()), internal_id: 42 };
307 let json = serde_json::to_value(&msg).unwrap();
308 assert_eq!(json["type"], "session_created");
309 assert_eq!(json["internal_id"], 42);
310
311 let msg = OutgoingMessage::Event { seq: 1, event: serde_json::json!({"type":"text_delta","text":"hi"}) };
313 let json = serde_json::to_value(&msg).unwrap();
314 assert_eq!(json["seq"], 1);
315 assert_eq!(json["text"], "hi"); let msg = OutgoingMessage::ToolCall {
319 seq: 2,
320 call_id: "c1".into(),
321 name: "shell".into(),
322 args: serde_json::json!({"cmd":"ls"}),
323 };
324 let json = serde_json::to_value(&msg).unwrap();
325 assert_eq!(json["type"], "tool_call");
326 assert_eq!(json["call_id"], "c1");
327
328 let msg = OutgoingMessage::ToolRegistered { name: "shell".into(), ok: true };
330 let json = serde_json::to_value(&msg).unwrap();
331 assert_eq!(json["type"], "tool_registered");
332 assert!(json["ok"].as_bool().unwrap());
333
334 let msg = OutgoingMessage::Done { seq: 3, outcome: "completed".into(), error: None, turns: Some(1) };
336 let json = serde_json::to_value(&msg).unwrap();
337 assert_eq!(json["type"], "done");
338 assert_eq!(json["outcome"], "completed");
339 assert!(json.get("error").is_none());
340
341 let msg = OutgoingMessage::Done {
343 seq: 4,
344 outcome: "failed".into(),
345 error: Some("something went wrong".into()),
346 turns: None,
347 };
348 let json = serde_json::to_value(&msg).unwrap();
349 assert_eq!(json["error"], "something went wrong");
350 assert!(json.get("turns").is_none());
351
352 let msg = OutgoingMessage::Error { code: "E001".into(), message: "bad request".into(), detail: None };
354 let json = serde_json::to_value(&msg).unwrap();
355 assert_eq!(json["type"], "error");
356 assert!(json.get("detail").is_none());
357
358 let msg = OutgoingMessage::ToolsListed { tools: vec![] };
360 let json = serde_json::to_value(&msg).unwrap();
361 assert_eq!(json["type"], "tools_listed");
362 }
363
364 #[test]
367 fn test_tool_metadata_from_agent_tool_metadata() {
368 let am = AgentToolMetadata {
369 name: "shell".into(),
370 description: "Run shell commands".into(),
371 origin: "phi-tools".into(),
372 version: "1.0.0".into(),
373 requirements: vec!["bash".into()],
374 };
375 let tm = ToolMetadata::from(am);
376 assert_eq!(tm.name, "shell");
377 assert_eq!(tm.description, "Run shell commands");
378 assert_eq!(tm.origin, "phi-tools");
379 assert_eq!(tm.version, "1.0.0");
380 assert_eq!(tm.requirements, vec!["bash"]);
381 }
382
383 #[test]
384 fn test_tool_metadata_round_trip() {
385 let tm = ToolMetadata {
386 name: "shell".into(),
387 description: "desc".into(),
388 origin: "phi".into(),
389 version: "1.0".into(),
390 requirements: vec!["bash".into(), "zsh".into()],
391 };
392 let json = serde_json::to_string(&tm).unwrap();
393 let back: ToolMetadata = serde_json::from_str(&json).unwrap();
394 assert_eq!(back.name, tm.name);
395 assert_eq!(back.description, tm.description);
396 assert_eq!(back.origin, tm.origin);
397 assert_eq!(back.version, tm.version);
398 assert_eq!(back.requirements, tm.requirements);
399 }
400}
401
402#[cfg(test)]
403mod proptests {
404 use super::*;
405
406 proptest::proptest! {
407 #[test]
408 fn incoming_message_deser_never_panics(json in ".*") {
409 let _ = serde_json::from_str::<IncomingMessage>(&json);
410 }
411
412 #[test]
413 fn incoming_message_unknown_type_returns_err(type_name in "[a-z_]{1,20}") {
414 let known = ["register_tool", "create_session", "run", "tool_result", "cancel", "list_tools"];
415 let json = format!(r#"{{"type":"{}","name":"x","description":"d","parameters":{{}},"session_id":"s","query":"q","call_id":"c","summary":"s"}}"#, type_name);
416 let result = serde_json::from_str::<IncomingMessage>(&json);
417 if known.contains(&type_name.as_str()) {
418 proptest::prop_assert!(result.is_ok(), "known type '{}' should deserialize", type_name);
419 } else {
420 proptest::prop_assert!(result.is_err(), "unknown type '{}' should fail", type_name);
421 }
422 }
423 }
424}