1use serde::{Deserialize, Serialize};
2
3use crate::{
4 ToolEventBuildError, ToolEventSubscriptionId, ToolEventTypeV1, MAX_TOOL_EVENT_CALL_ID_BYTES,
5 MAX_TOOL_EVENT_JSON_BYTES, MAX_TOOL_EVENT_PATH_BYTES, MAX_TOOL_EVENT_ROOT_SESSION_ID_BYTES,
6 MAX_TOOL_EVENT_SESSION_ID_BYTES, MAX_TOOL_EVENT_TOOL_NAME_BYTES, TOOL_EVENT_V1_SCHEMA_VERSION,
7};
8
9pub const MAX_PROJECTED_TOOL_EVENT_DIFF_BYTES: usize = 4 * 1024;
12pub const MAX_PROJECTED_TOOL_EVENT_CONTENT_BYTES: usize = 8 * 1024;
13pub const TOOL_EVENT_PATH_REDACTION_PERMISSION_NOT_GRANTED: &str = "permission_not_granted";
14pub const TOOL_EVENT_PATH_REDACTION_SENSITIVE: &str = "sensitive_path";
15pub const TOOL_EVENT_PATH_REDACTION_UNSAFE: &str = "unsafe_path";
16
17#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
20pub struct ProjectedToolEventContextV1 {
21 pub session_id: String,
22 pub root_session_id: String,
23 #[serde(default, skip_serializing_if = "Option::is_none")]
24 pub tool_name: Option<String>,
25 pub tool_call_id: String,
26}
27
28#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
32pub struct ProjectedFileChangedV1 {
33 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub path: Option<String>,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub path_redaction_reason: Option<String>,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub diff: Option<String>,
39 #[serde(default, skip_serializing_if = "is_false")]
40 pub diff_truncated: bool,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub content: Option<String>,
43 #[serde(default, skip_serializing_if = "is_false")]
44 pub content_truncated: bool,
45}
46
47fn is_false(value: &bool) -> bool {
48 !*value
49}
50
51#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
55pub struct ProjectedToolEventV1 {
56 pub schema_version: u16,
57 pub event_type: ToolEventTypeV1,
58 pub subscription_id: ToolEventSubscriptionId,
59 pub context: ProjectedToolEventContextV1,
60 pub data: ProjectedFileChangedV1,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub observation_policy_generation: Option<u64>,
65}
66
67impl ProjectedToolEventV1 {
68 pub fn file_changed(
69 context: ProjectedToolEventContextV1,
70 data: ProjectedFileChangedV1,
71 observation_policy_generation: u64,
72 ) -> Self {
73 Self {
74 schema_version: TOOL_EVENT_V1_SCHEMA_VERSION,
75 event_type: ToolEventTypeV1::file_changed(),
76 subscription_id: ToolEventSubscriptionId::file_changed_v1(),
77 context,
78 data,
79 observation_policy_generation: Some(observation_policy_generation),
80 }
81 }
82
83 pub fn validate_bounds(&self) -> Result<(), ToolEventBuildError> {
85 if self.schema_version != TOOL_EVENT_V1_SCHEMA_VERSION {
86 return Err(ToolEventBuildError::UnsupportedSchemaVersion {
87 actual: self.schema_version,
88 supported: TOOL_EVENT_V1_SCHEMA_VERSION,
89 });
90 }
91 if !self.event_type.is_file_changed() || !self.subscription_id.is_file_changed_v1() {
92 return Err(ToolEventBuildError::KnownVariantMismatch);
93 }
94 validate_projected_field(
95 "context.session_id",
96 &self.context.session_id,
97 MAX_TOOL_EVENT_SESSION_ID_BYTES,
98 )?;
99 validate_projected_field(
100 "context.root_session_id",
101 &self.context.root_session_id,
102 MAX_TOOL_EVENT_ROOT_SESSION_ID_BYTES,
103 )?;
104 validate_projected_field(
105 "context.tool_call_id",
106 &self.context.tool_call_id,
107 MAX_TOOL_EVENT_CALL_ID_BYTES,
108 )?;
109 if let Some(tool_name) = &self.context.tool_name {
110 validate_projected_field(
111 "context.tool_name",
112 tool_name,
113 MAX_TOOL_EVENT_TOOL_NAME_BYTES,
114 )?;
115 }
116 match (&self.data.path, &self.data.path_redaction_reason) {
117 (Some(path), None) => {
118 validate_projected_field("data.path", path, MAX_TOOL_EVENT_PATH_BYTES)?;
119 }
120 (None, Some(reason))
121 if matches!(
122 reason.as_str(),
123 TOOL_EVENT_PATH_REDACTION_PERMISSION_NOT_GRANTED
124 | TOOL_EVENT_PATH_REDACTION_SENSITIVE
125 | TOOL_EVENT_PATH_REDACTION_UNSAFE
126 ) =>
127 {
128 if self.data.diff.is_some()
129 || self.data.content.is_some()
130 || self.data.diff_truncated
131 || self.data.content_truncated
132 {
133 return Err(ToolEventBuildError::InvalidKnownPayload(
134 "redacted path projection must not carry diff/content".to_string(),
135 ));
136 }
137 }
138 _ => {
139 return Err(ToolEventBuildError::InvalidKnownPayload(
140 "file_changed projection requires exactly one of path or path_redaction_reason"
141 .to_string(),
142 ));
143 }
144 }
145 if let Some(diff) = &self.data.diff {
146 validate_projected_field("data.diff", diff, MAX_PROJECTED_TOOL_EVENT_DIFF_BYTES)?;
147 } else if self.data.diff_truncated {
148 return Err(ToolEventBuildError::InvalidKnownPayload(
149 "diff_truncated requires diff".to_string(),
150 ));
151 }
152 if let Some(content) = &self.data.content {
153 validate_projected_field(
154 "data.content",
155 content,
156 MAX_PROJECTED_TOOL_EVENT_CONTENT_BYTES,
157 )?;
158 } else if self.data.content_truncated {
159 return Err(ToolEventBuildError::InvalidKnownPayload(
160 "content_truncated requires content".to_string(),
161 ));
162 }
163 if self.observation_policy_generation == Some(0) {
164 return Err(ToolEventBuildError::InvalidKnownPayload(
165 "observation_policy_generation must be positive".to_string(),
166 ));
167 }
168 let actual = serde_json::to_vec(self)
169 .map_err(|error| ToolEventBuildError::Serialization(error.to_string()))?
170 .len();
171 if actual > MAX_TOOL_EVENT_JSON_BYTES {
172 return Err(ToolEventBuildError::EventTooLarge {
173 actual,
174 max: MAX_TOOL_EVENT_JSON_BYTES,
175 });
176 }
177 Ok(())
178 }
179}
180
181fn validate_projected_field(
182 field: &'static str,
183 value: &str,
184 max: usize,
185) -> Result<(), ToolEventBuildError> {
186 if value.trim().is_empty() {
187 return Err(ToolEventBuildError::EmptyField { field });
188 }
189 if value.len() > max {
190 return Err(ToolEventBuildError::FieldTooLarge {
191 field,
192 actual: value.len(),
193 max,
194 });
195 }
196 Ok(())
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202 use crate::ToolEventSubscriptionId;
203
204 #[test]
205 fn absent_observation_fields_are_absent_on_the_wire() {
206 let projected = ProjectedToolEventV1::file_changed(
207 ProjectedToolEventContextV1 {
208 session_id: "session".to_string(),
209 root_session_id: "root".to_string(),
210 tool_name: None,
211 tool_call_id: "call".to_string(),
212 },
213 ProjectedFileChangedV1 {
214 path_redaction_reason: Some("permission_not_granted".to_string()),
215 ..ProjectedFileChangedV1::default()
216 },
217 1,
218 );
219 let value = serde_json::to_value(&projected).unwrap();
220 assert!(value["context"].get("tool_name").is_none());
221 assert!(value["data"].get("path").is_none());
222 assert_eq!(
223 value["data"]["path_redaction_reason"],
224 "permission_not_granted"
225 );
226 projected.validate_bounds().unwrap();
227 }
228
229 #[test]
230 fn original_full_observation_golden_remains_deserializable() {
231 let projected: ProjectedToolEventV1 = serde_json::from_str(include_str!(
232 "../tests/golden/tool_event_v1.file_changed.json"
233 ))
234 .unwrap();
235 assert_eq!(projected.context.tool_name.as_deref(), Some("Write"));
236 assert_eq!(
237 projected.data.path.as_deref(),
238 Some("/workspace/zenith/src/lib.rs")
239 );
240 assert_eq!(projected.observation_policy_generation, None);
241 projected.validate_bounds().unwrap();
242 }
243
244 #[test]
245 fn invalid_identifier_and_mixed_path_projection_are_rejected() {
246 let mut projected: ProjectedToolEventV1 = serde_json::from_str(include_str!(
247 "../tests/golden/tool_event_v1.file_changed.json"
248 ))
249 .unwrap();
250 projected.subscription_id = ToolEventSubscriptionId::new("tool.future.v1");
251 assert_eq!(
252 projected.validate_bounds(),
253 Err(ToolEventBuildError::KnownVariantMismatch)
254 );
255
256 projected.subscription_id = ToolEventSubscriptionId::file_changed_v1();
257 projected.data.path_redaction_reason =
258 Some(TOOL_EVENT_PATH_REDACTION_SENSITIVE.to_string());
259 assert!(matches!(
260 projected.validate_bounds(),
261 Err(ToolEventBuildError::InvalidKnownPayload(_))
262 ));
263 }
264}