Skip to main content

atd_protocol/
result.rs

1use serde::{Deserialize, Serialize};
2
3use crate::enums::BindingProtocol;
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
6#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
7#[serde(tag = "status", rename_all = "snake_case")]
8pub enum ToolResult {
9    Success {
10        data: serde_json::Value,
11        metadata: ToolResultMetadata,
12    },
13    Error {
14        code: String,
15        message: String,
16        reason: Option<String>,
17        retryable: bool,
18    },
19}
20
21/// Metadata attached to a successful tool result.
22///
23/// Only `tool_id` is required — servers always echo it. All other fields are
24/// server-populated and optional: clients must not synthesize them, because a
25/// fabricated `timestamp`/`request_id` would silently masquerade as server
26/// truth. `timestamp` and `request_id` are kept as opaque strings so this crate
27/// has no dependency on a specific datetime or ULID implementation; callers
28/// that need typed access can parse with their preferred library.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
31pub struct ToolResultMetadata {
32    pub tool_id: String,
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub version: Option<String>,
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub binding: Option<BindingProtocol>,
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub latency_ms: Option<u64>,
39    /// ISO-8601 / RFC-3339 timestamp, server-populated.
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub timestamp: Option<String>,
42    /// Opaque request identifier (ULID, UUID, or whatever the server emits).
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub request_id: Option<String>,
45}
46
47impl ToolResultMetadata {
48    /// Construct minimal metadata with only `tool_id` set.
49    /// The canonical way for clients to create metadata — everything else
50    /// is for the server to fill in.
51    pub fn for_tool(tool_id: impl Into<String>) -> Self {
52        Self {
53            tool_id: tool_id.into(),
54            version: None,
55            binding: None,
56            latency_ms: None,
57            timestamp: None,
58            request_id: None,
59        }
60    }
61}
62
63impl ToolResult {
64    pub fn is_success(&self) -> bool {
65        matches!(self, ToolResult::Success { .. })
66    }
67
68    pub fn is_retryable(&self) -> bool {
69        matches!(
70            self,
71            ToolResult::Error {
72                retryable: true,
73                ..
74            }
75        )
76    }
77
78    pub fn data(&self) -> Option<&serde_json::Value> {
79        match self {
80            ToolResult::Success { data, .. } => Some(data),
81            _ => None,
82        }
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    fn success() -> ToolResult {
91        ToolResult::Success {
92            data: serde_json::json!({"content": "hello"}),
93            metadata: ToolResultMetadata::for_tool("anos:fs.read"),
94        }
95    }
96
97    #[test]
98    fn success_roundtrip() {
99        let r = success();
100        let j = serde_json::to_string(&r).unwrap();
101        let back: ToolResult = serde_json::from_str(&j).unwrap();
102        assert!(back.is_success());
103        assert_eq!(back.data().unwrap()["content"], "hello");
104    }
105
106    #[test]
107    fn error_retryable() {
108        let r = ToolResult::Error {
109            code: "TIMEOUT".into(),
110            message: "timed out".into(),
111            reason: None,
112            retryable: true,
113        };
114        assert!(!r.is_success());
115        assert!(r.is_retryable());
116    }
117
118    #[test]
119    fn status_tag_uses_snake_case() {
120        let j = serde_json::to_string(&success()).unwrap();
121        assert!(j.contains("\"status\":\"success\""), "got: {j}");
122    }
123
124    #[test]
125    fn metadata_with_only_tool_id_serializes_without_null_fields() {
126        let m = ToolResultMetadata::for_tool("anos:fs.read");
127        let j = serde_json::to_string(&m).unwrap();
128        assert_eq!(j, r#"{"tool_id":"anos:fs.read"}"#);
129    }
130
131    #[test]
132    fn metadata_roundtrips_with_missing_optional_fields() {
133        let j = r#"{"tool_id":"anos:fs.read"}"#;
134        let m: ToolResultMetadata = serde_json::from_str(j).unwrap();
135        assert_eq!(m.tool_id, "anos:fs.read");
136        assert!(m.version.is_none());
137        assert!(m.binding.is_none());
138        assert!(m.latency_ms.is_none());
139        assert!(m.timestamp.is_none());
140        assert!(m.request_id.is_none());
141    }
142
143    #[test]
144    fn metadata_roundtrips_with_server_populated_fields() {
145        let j = r#"{"tool_id":"anos:fs.read","version":"0.1.2","binding":"Cli","latency_ms":7,"timestamp":"2026-04-21T10:00:00Z","request_id":"01HY..."}"#;
146        let m: ToolResultMetadata = serde_json::from_str(j).unwrap();
147        assert_eq!(m.tool_id, "anos:fs.read");
148        assert_eq!(m.version.as_deref(), Some("0.1.2"));
149        assert_eq!(m.binding, Some(BindingProtocol::Cli));
150        assert_eq!(m.latency_ms, Some(7));
151        assert!(m.timestamp.is_some());
152        assert!(m.request_id.is_some());
153    }
154}