use serde::{Deserialize, Serialize};
use crate::{
ToolEventBuildError, ToolEventSubscriptionId, ToolEventTypeV1, MAX_TOOL_EVENT_CALL_ID_BYTES,
MAX_TOOL_EVENT_JSON_BYTES, MAX_TOOL_EVENT_PATH_BYTES, MAX_TOOL_EVENT_ROOT_SESSION_ID_BYTES,
MAX_TOOL_EVENT_SESSION_ID_BYTES, MAX_TOOL_EVENT_TOOL_NAME_BYTES, TOOL_EVENT_V1_SCHEMA_VERSION,
};
pub const MAX_PROJECTED_TOOL_EVENT_DIFF_BYTES: usize = 4 * 1024;
pub const MAX_PROJECTED_TOOL_EVENT_CONTENT_BYTES: usize = 8 * 1024;
pub const TOOL_EVENT_PATH_REDACTION_PERMISSION_NOT_GRANTED: &str = "permission_not_granted";
pub const TOOL_EVENT_PATH_REDACTION_SENSITIVE: &str = "sensitive_path";
pub const TOOL_EVENT_PATH_REDACTION_UNSAFE: &str = "unsafe_path";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProjectedToolEventContextV1 {
pub session_id: String,
pub root_session_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_name: Option<String>,
pub tool_call_id: String,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProjectedFileChangedV1 {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path_redaction_reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub diff: Option<String>,
#[serde(default, skip_serializing_if = "is_false")]
pub diff_truncated: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
#[serde(default, skip_serializing_if = "is_false")]
pub content_truncated: bool,
}
fn is_false(value: &bool) -> bool {
!*value
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProjectedToolEventV1 {
pub schema_version: u16,
pub event_type: ToolEventTypeV1,
pub subscription_id: ToolEventSubscriptionId,
pub context: ProjectedToolEventContextV1,
pub data: ProjectedFileChangedV1,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub observation_policy_generation: Option<u64>,
}
impl ProjectedToolEventV1 {
pub fn file_changed(
context: ProjectedToolEventContextV1,
data: ProjectedFileChangedV1,
observation_policy_generation: u64,
) -> Self {
Self {
schema_version: TOOL_EVENT_V1_SCHEMA_VERSION,
event_type: ToolEventTypeV1::file_changed(),
subscription_id: ToolEventSubscriptionId::file_changed_v1(),
context,
data,
observation_policy_generation: Some(observation_policy_generation),
}
}
pub fn validate_bounds(&self) -> Result<(), ToolEventBuildError> {
if self.schema_version != TOOL_EVENT_V1_SCHEMA_VERSION {
return Err(ToolEventBuildError::UnsupportedSchemaVersion {
actual: self.schema_version,
supported: TOOL_EVENT_V1_SCHEMA_VERSION,
});
}
if !self.event_type.is_file_changed() || !self.subscription_id.is_file_changed_v1() {
return Err(ToolEventBuildError::KnownVariantMismatch);
}
validate_projected_field(
"context.session_id",
&self.context.session_id,
MAX_TOOL_EVENT_SESSION_ID_BYTES,
)?;
validate_projected_field(
"context.root_session_id",
&self.context.root_session_id,
MAX_TOOL_EVENT_ROOT_SESSION_ID_BYTES,
)?;
validate_projected_field(
"context.tool_call_id",
&self.context.tool_call_id,
MAX_TOOL_EVENT_CALL_ID_BYTES,
)?;
if let Some(tool_name) = &self.context.tool_name {
validate_projected_field(
"context.tool_name",
tool_name,
MAX_TOOL_EVENT_TOOL_NAME_BYTES,
)?;
}
match (&self.data.path, &self.data.path_redaction_reason) {
(Some(path), None) => {
validate_projected_field("data.path", path, MAX_TOOL_EVENT_PATH_BYTES)?;
}
(None, Some(reason))
if matches!(
reason.as_str(),
TOOL_EVENT_PATH_REDACTION_PERMISSION_NOT_GRANTED
| TOOL_EVENT_PATH_REDACTION_SENSITIVE
| TOOL_EVENT_PATH_REDACTION_UNSAFE
) =>
{
if self.data.diff.is_some()
|| self.data.content.is_some()
|| self.data.diff_truncated
|| self.data.content_truncated
{
return Err(ToolEventBuildError::InvalidKnownPayload(
"redacted path projection must not carry diff/content".to_string(),
));
}
}
_ => {
return Err(ToolEventBuildError::InvalidKnownPayload(
"file_changed projection requires exactly one of path or path_redaction_reason"
.to_string(),
));
}
}
if let Some(diff) = &self.data.diff {
validate_projected_field("data.diff", diff, MAX_PROJECTED_TOOL_EVENT_DIFF_BYTES)?;
} else if self.data.diff_truncated {
return Err(ToolEventBuildError::InvalidKnownPayload(
"diff_truncated requires diff".to_string(),
));
}
if let Some(content) = &self.data.content {
validate_projected_field(
"data.content",
content,
MAX_PROJECTED_TOOL_EVENT_CONTENT_BYTES,
)?;
} else if self.data.content_truncated {
return Err(ToolEventBuildError::InvalidKnownPayload(
"content_truncated requires content".to_string(),
));
}
if self.observation_policy_generation == Some(0) {
return Err(ToolEventBuildError::InvalidKnownPayload(
"observation_policy_generation must be positive".to_string(),
));
}
let actual = serde_json::to_vec(self)
.map_err(|error| ToolEventBuildError::Serialization(error.to_string()))?
.len();
if actual > MAX_TOOL_EVENT_JSON_BYTES {
return Err(ToolEventBuildError::EventTooLarge {
actual,
max: MAX_TOOL_EVENT_JSON_BYTES,
});
}
Ok(())
}
}
fn validate_projected_field(
field: &'static str,
value: &str,
max: usize,
) -> Result<(), ToolEventBuildError> {
if value.trim().is_empty() {
return Err(ToolEventBuildError::EmptyField { field });
}
if value.len() > max {
return Err(ToolEventBuildError::FieldTooLarge {
field,
actual: value.len(),
max,
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ToolEventSubscriptionId;
#[test]
fn absent_observation_fields_are_absent_on_the_wire() {
let projected = ProjectedToolEventV1::file_changed(
ProjectedToolEventContextV1 {
session_id: "session".to_string(),
root_session_id: "root".to_string(),
tool_name: None,
tool_call_id: "call".to_string(),
},
ProjectedFileChangedV1 {
path_redaction_reason: Some("permission_not_granted".to_string()),
..ProjectedFileChangedV1::default()
},
1,
);
let value = serde_json::to_value(&projected).unwrap();
assert!(value["context"].get("tool_name").is_none());
assert!(value["data"].get("path").is_none());
assert_eq!(
value["data"]["path_redaction_reason"],
"permission_not_granted"
);
projected.validate_bounds().unwrap();
}
#[test]
fn original_full_observation_golden_remains_deserializable() {
let projected: ProjectedToolEventV1 = serde_json::from_str(include_str!(
"../tests/golden/tool_event_v1.file_changed.json"
))
.unwrap();
assert_eq!(projected.context.tool_name.as_deref(), Some("Write"));
assert_eq!(
projected.data.path.as_deref(),
Some("/workspace/zenith/src/lib.rs")
);
assert_eq!(projected.observation_policy_generation, None);
projected.validate_bounds().unwrap();
}
#[test]
fn invalid_identifier_and_mixed_path_projection_are_rejected() {
let mut projected: ProjectedToolEventV1 = serde_json::from_str(include_str!(
"../tests/golden/tool_event_v1.file_changed.json"
))
.unwrap();
projected.subscription_id = ToolEventSubscriptionId::new("tool.future.v1");
assert_eq!(
projected.validate_bounds(),
Err(ToolEventBuildError::KnownVariantMismatch)
);
projected.subscription_id = ToolEventSubscriptionId::file_changed_v1();
projected.data.path_redaction_reason =
Some(TOOL_EVENT_PATH_REDACTION_SENSITIVE.to_string());
assert!(matches!(
projected.validate_bounds(),
Err(ToolEventBuildError::InvalidKnownPayload(_))
));
}
}