1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4use serde_json::{json, Map, Value};
5use thiserror::Error;
6
7use crate::projected_tool_event_v1::{
8 MAX_PROJECTED_TOOL_EVENT_CONTENT_BYTES, MAX_PROJECTED_TOOL_EVENT_DIFF_BYTES,
9 TOOL_EVENT_PATH_REDACTION_PERMISSION_NOT_GRANTED, TOOL_EVENT_PATH_REDACTION_SENSITIVE,
10 TOOL_EVENT_PATH_REDACTION_UNSAFE,
11};
12
13pub const TOOL_EVENT_V1_SCHEMA_VERSION: u16 = 1;
15pub const TOOL_EVENT_PROTOCOL_NAME: &str = "tool_event";
17pub const FILE_CHANGED_EVENT_TYPE_V1: &str = "file_changed";
19pub const FILE_CHANGED_SUBSCRIPTION_ID_V1: &str = "tool.file_changed.v1";
21
22pub const MAX_TOOL_EVENT_SESSION_ID_BYTES: usize = 256;
26pub const MAX_TOOL_EVENT_ROOT_SESSION_ID_BYTES: usize = 256;
27pub const MAX_TOOL_EVENT_TYPE_BYTES: usize = 128;
28pub const MAX_TOOL_EVENT_SUBSCRIPTION_ID_BYTES: usize = 128;
29pub const MAX_TOOL_EVENT_TOOL_NAME_BYTES: usize = 128;
30pub const MAX_TOOL_EVENT_CALL_ID_BYTES: usize = 256;
31pub const MAX_TOOL_EVENT_PATH_BYTES: usize = 4096;
32pub const MAX_TOOL_EVENT_JSON_BYTES: usize = 16 * 1024;
33
34#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
36#[serde(transparent)]
37pub struct ToolEventTypeV1(String);
38
39impl ToolEventTypeV1 {
40 pub fn new(value: impl Into<String>) -> Self {
41 Self(value.into())
42 }
43
44 pub fn file_changed() -> Self {
45 Self(FILE_CHANGED_EVENT_TYPE_V1.to_string())
46 }
47
48 pub fn as_str(&self) -> &str {
49 &self.0
50 }
51
52 pub fn is_file_changed(&self) -> bool {
53 self.0 == FILE_CHANGED_EVENT_TYPE_V1
54 }
55}
56
57#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
59#[serde(transparent)]
60pub struct ToolEventSubscriptionId(String);
61
62impl ToolEventSubscriptionId {
63 pub fn new(value: impl Into<String>) -> Self {
64 Self(value.into())
65 }
66
67 pub fn file_changed_v1() -> Self {
68 Self(FILE_CHANGED_SUBSCRIPTION_ID_V1.to_string())
69 }
70
71 pub fn as_str(&self) -> &str {
72 &self.0
73 }
74
75 pub fn is_file_changed_v1(&self) -> bool {
76 self.0 == FILE_CHANGED_SUBSCRIPTION_ID_V1
77 }
78}
79
80#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
82pub struct ToolEventContextV1 {
83 pub session_id: String,
84 pub root_session_id: String,
86 pub tool_name: String,
88 pub tool_call_id: String,
90 #[serde(default, flatten, skip_serializing_if = "BTreeMap::is_empty")]
92 pub extensions: BTreeMap<String, Value>,
93}
94
95impl ToolEventContextV1 {
96 pub fn bounded_from(
98 session_id: &str,
99 root_session_id: &str,
100 tool_name: &str,
101 tool_call_id: &str,
102 ) -> Result<Self, ToolEventBuildError> {
103 validate_required(
104 "context.session_id",
105 session_id,
106 MAX_TOOL_EVENT_SESSION_ID_BYTES,
107 )?;
108 validate_required(
109 "context.root_session_id",
110 root_session_id,
111 MAX_TOOL_EVENT_ROOT_SESSION_ID_BYTES,
112 )?;
113 validate_required(
114 "context.tool_name",
115 tool_name,
116 MAX_TOOL_EVENT_TOOL_NAME_BYTES,
117 )?;
118 validate_required(
119 "context.tool_call_id",
120 tool_call_id,
121 MAX_TOOL_EVENT_CALL_ID_BYTES,
122 )?;
123 Ok(Self {
124 session_id: session_id.to_string(),
125 root_session_id: root_session_id.to_string(),
126 tool_name: tool_name.to_string(),
127 tool_call_id: tool_call_id.to_string(),
128 extensions: BTreeMap::new(),
129 })
130 }
131
132 pub fn bounded(
133 session_id: impl Into<String>,
134 root_session_id: impl Into<String>,
135 tool_name: impl Into<String>,
136 tool_call_id: impl Into<String>,
137 ) -> Result<Self, ToolEventBuildError> {
138 let context = Self {
139 session_id: session_id.into(),
140 root_session_id: root_session_id.into(),
141 tool_name: tool_name.into(),
142 tool_call_id: tool_call_id.into(),
143 extensions: BTreeMap::new(),
144 };
145 context.validate_bounds()?;
146 Ok(context)
147 }
148
149 fn validate_bounds(&self) -> Result<(), ToolEventBuildError> {
150 validate_extension_keys(
151 "context",
152 &self.extensions,
153 &["session_id", "root_session_id", "tool_name", "tool_call_id"],
154 )?;
155 validate_required(
156 "context.session_id",
157 &self.session_id,
158 MAX_TOOL_EVENT_SESSION_ID_BYTES,
159 )?;
160 validate_required(
161 "context.root_session_id",
162 &self.root_session_id,
163 MAX_TOOL_EVENT_ROOT_SESSION_ID_BYTES,
164 )?;
165 validate_required(
166 "context.tool_name",
167 &self.tool_name,
168 MAX_TOOL_EVENT_TOOL_NAME_BYTES,
169 )?;
170 validate_required(
171 "context.tool_call_id",
172 &self.tool_call_id,
173 MAX_TOOL_EVENT_CALL_ID_BYTES,
174 )
175 }
176}
177
178#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
183pub struct FileChangedV1 {
184 pub path: String,
185 #[serde(default, flatten, skip_serializing_if = "BTreeMap::is_empty")]
187 pub extensions: BTreeMap<String, Value>,
188}
189
190impl FileChangedV1 {
191 pub fn bounded_from(path: &str) -> Result<Self, ToolEventBuildError> {
193 validate_required("data.path", path, MAX_TOOL_EVENT_PATH_BYTES)?;
194 Ok(Self {
195 path: path.to_string(),
196 extensions: BTreeMap::new(),
197 })
198 }
199
200 pub fn bounded(path: impl Into<String>) -> Result<Self, ToolEventBuildError> {
201 let data = Self {
202 path: path.into(),
203 extensions: BTreeMap::new(),
204 };
205 data.validate_bounds()?;
206 Ok(data)
207 }
208
209 fn validate_bounds(&self) -> Result<(), ToolEventBuildError> {
210 validate_extension_keys("data", &self.extensions, &["path"])?;
211 validate_required("data.path", &self.path, MAX_TOOL_EVENT_PATH_BYTES)
212 }
213}
214
215#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
222pub struct ToolEventV1 {
223 pub schema_version: u16,
224 pub event_type: ToolEventTypeV1,
225 pub subscription_id: ToolEventSubscriptionId,
226 pub context: ToolEventContextV1,
227 pub data: Value,
228 #[serde(default, flatten, skip_serializing_if = "BTreeMap::is_empty")]
230 pub extensions: BTreeMap<String, Value>,
231}
232
233impl ToolEventV1 {
234 pub fn file_changed(
235 context: ToolEventContextV1,
236 data: FileChangedV1,
237 ) -> Result<Self, ToolEventBuildError> {
238 context.validate_bounds()?;
243 data.validate_bounds()?;
244 let FileChangedV1 { path, extensions } = data;
245 let mut data = Map::new();
246 data.insert("path".to_string(), Value::String(path));
247 data.extend(extensions);
248 let event = Self {
249 schema_version: TOOL_EVENT_V1_SCHEMA_VERSION,
250 event_type: ToolEventTypeV1::file_changed(),
251 subscription_id: ToolEventSubscriptionId::file_changed_v1(),
252 context,
253 data: Value::Object(data),
254 extensions: BTreeMap::new(),
255 };
256 event.validate_projection_input_bounds()?;
257 Ok(event)
258 }
259
260 pub fn file_changed_data(&self) -> Option<Result<FileChangedV1, serde_json::Error>> {
263 (self.schema_version == TOOL_EVENT_V1_SCHEMA_VERSION
264 && self.event_type.is_file_changed()
265 && self.subscription_id.is_file_changed_v1())
266 .then(|| serde_json::from_value(self.data.clone()))
267 }
268
269 pub fn validate_bounds(&self) -> Result<(), ToolEventBuildError> {
271 self.validate_projection_input_bounds()?;
272
273 let actual = serde_json::to_vec(self)
278 .map_err(|error| ToolEventBuildError::Serialization(error.to_string()))?
279 .len();
280 if actual > MAX_TOOL_EVENT_JSON_BYTES {
281 return Err(ToolEventBuildError::EventTooLarge {
282 actual,
283 max: MAX_TOOL_EVENT_JSON_BYTES,
284 });
285 }
286 Ok(())
287 }
288
289 pub fn validate_projection_input_bounds(&self) -> Result<(), ToolEventBuildError> {
297 if self.schema_version != TOOL_EVENT_V1_SCHEMA_VERSION {
298 return Err(ToolEventBuildError::UnsupportedSchemaVersion {
299 actual: self.schema_version,
300 supported: TOOL_EVENT_V1_SCHEMA_VERSION,
301 });
302 }
303 validate_extension_keys(
304 "envelope",
305 &self.extensions,
306 &[
307 "schema_version",
308 "event_type",
309 "subscription_id",
310 "context",
311 "data",
312 ],
313 )?;
314 self.context.validate_bounds()?;
315 validate_required(
316 "event_type",
317 self.event_type.as_str(),
318 MAX_TOOL_EVENT_TYPE_BYTES,
319 )?;
320 validate_required(
321 "subscription_id",
322 self.subscription_id.as_str(),
323 MAX_TOOL_EVENT_SUBSCRIPTION_ID_BYTES,
324 )?;
325
326 if !self.data.is_object() {
327 return Err(ToolEventBuildError::DataMustBeObject);
328 }
329
330 let event_known = self.event_type.is_file_changed();
331 let subscription_known = self.subscription_id.is_file_changed_v1();
332 if event_known != subscription_known {
333 return Err(ToolEventBuildError::KnownVariantMismatch);
334 }
335 if event_known {
336 let path = self
337 .data
338 .as_object()
339 .and_then(|data| data.get("path"))
340 .and_then(Value::as_str)
341 .ok_or_else(|| {
342 ToolEventBuildError::InvalidKnownPayload(
343 "file_changed data.path must be a string".to_string(),
344 )
345 })?;
346 validate_required("data.path", path, MAX_TOOL_EVENT_PATH_BYTES)?;
347 }
348 Ok(())
349 }
350}
351
352fn validate_required(
353 field: &'static str,
354 value: &str,
355 max: usize,
356) -> Result<(), ToolEventBuildError> {
357 if value.trim().is_empty() {
358 return Err(ToolEventBuildError::EmptyField { field });
359 }
360 let actual = value.len();
361 if actual > max {
362 return Err(ToolEventBuildError::FieldTooLarge { field, actual, max });
363 }
364 Ok(())
365}
366
367fn validate_extension_keys(
368 location: &'static str,
369 extensions: &BTreeMap<String, Value>,
370 reserved: &[&str],
371) -> Result<(), ToolEventBuildError> {
372 if let Some(key) = reserved.iter().find(|key| extensions.contains_key(**key)) {
373 return Err(ToolEventBuildError::ReservedExtensionKey {
374 location,
375 key: (*key).to_string(),
376 });
377 }
378 Ok(())
379}
380
381#[derive(Clone, Debug, Error, PartialEq, Eq)]
382pub enum ToolEventBuildError {
383 #[error("tool event field `{field}` must not be empty")]
384 EmptyField { field: &'static str },
385 #[error("tool event field `{field}` is {actual} bytes; maximum is {max}")]
386 FieldTooLarge {
387 field: &'static str,
388 actual: usize,
389 max: usize,
390 },
391 #[error("tool event is {actual} bytes; maximum is {max}")]
392 EventTooLarge { actual: usize, max: usize },
393 #[error("tool event data must be a JSON object")]
394 DataMustBeObject,
395 #[error("unsupported tool event schema version {actual}; supported version is {supported}")]
396 UnsupportedSchemaVersion { actual: u16, supported: u16 },
397 #[error("file_changed event type and subscription id must be paired")]
398 KnownVariantMismatch,
399 #[error("invalid known tool event payload: {0}")]
400 InvalidKnownPayload(String),
401 #[error("tool event {location} extension conflicts with reserved key `{key}`")]
402 ReservedExtensionKey { location: &'static str, key: String },
403 #[error("failed to serialize tool event: {0}")]
404 Serialization(String),
405}
406
407pub fn tool_event_v1_schema() -> Value {
413 json!({
414 "$schema": "https://json-schema.org/draft/2020-12/schema",
415 "$id": "https://bamboo.dev/schemas/plugin/tool-event-v1.schema.json",
416 "title": "ToolEventV1",
417 "type": "object",
418 "x-bamboo-maxJsonBytes": MAX_TOOL_EVENT_JSON_BYTES,
419 "required": ["schema_version", "event_type", "subscription_id", "context", "data"],
420 "properties": {
421 "schema_version": { "const": TOOL_EVENT_V1_SCHEMA_VERSION },
422 "event_type": { "type": "string", "minLength": 1, "x-bamboo-maxUtf8Bytes": MAX_TOOL_EVENT_TYPE_BYTES },
423 "subscription_id": { "type": "string", "minLength": 1, "x-bamboo-maxUtf8Bytes": MAX_TOOL_EVENT_SUBSCRIPTION_ID_BYTES },
424 "context": {
425 "type": "object",
426 "required": ["session_id", "root_session_id", "tool_call_id"],
427 "properties": {
428 "session_id": { "type": "string", "minLength": 1, "x-bamboo-maxUtf8Bytes": MAX_TOOL_EVENT_SESSION_ID_BYTES },
429 "root_session_id": { "type": "string", "minLength": 1, "x-bamboo-maxUtf8Bytes": MAX_TOOL_EVENT_ROOT_SESSION_ID_BYTES },
430 "tool_name": { "type": "string", "minLength": 1, "x-bamboo-maxUtf8Bytes": MAX_TOOL_EVENT_TOOL_NAME_BYTES },
431 "tool_call_id": { "type": "string", "minLength": 1, "x-bamboo-maxUtf8Bytes": MAX_TOOL_EVENT_CALL_ID_BYTES }
432 },
433 "additionalProperties": true
434 },
435 "data": { "type": "object" },
436 "observation_policy_generation": { "type": "integer", "minimum": 1 }
437 },
438 "allOf": [{
439 "if": {
440 "properties": {
441 "event_type": { "const": FILE_CHANGED_EVENT_TYPE_V1 }
442 },
443 "required": ["event_type"]
444 },
445 "then": {
446 "properties": {
447 "subscription_id": { "const": FILE_CHANGED_SUBSCRIPTION_ID_V1 },
448 "data": {
449 "type": "object",
450 "properties": {
451 "path": { "type": "string", "minLength": 1, "x-bamboo-maxUtf8Bytes": MAX_TOOL_EVENT_PATH_BYTES },
452 "path_redaction_reason": {
453 "type": "string",
454 "enum": [
455 TOOL_EVENT_PATH_REDACTION_PERMISSION_NOT_GRANTED,
456 TOOL_EVENT_PATH_REDACTION_SENSITIVE,
457 TOOL_EVENT_PATH_REDACTION_UNSAFE
458 ]
459 },
460 "diff": { "type": "string", "x-bamboo-maxUtf8Bytes": MAX_PROJECTED_TOOL_EVENT_DIFF_BYTES },
461 "diff_truncated": { "type": "boolean" },
462 "content": { "type": "string", "x-bamboo-maxUtf8Bytes": MAX_PROJECTED_TOOL_EVENT_CONTENT_BYTES },
463 "content_truncated": { "type": "boolean" }
464 },
465 "oneOf": [{
466 "required": ["path"],
467 "not": { "required": ["path_redaction_reason"] }
468 }, {
469 "required": ["path_redaction_reason"],
470 "not": { "required": ["path"] }
471 }],
472 "allOf": [{
473 "if": {
474 "anyOf": [
475 { "required": ["diff"] },
476 { "required": ["diff_truncated"] },
477 { "required": ["content"] },
478 { "required": ["content_truncated"] }
479 ]
480 },
481 "then": { "required": ["path"] }
482 }, {
483 "if": { "required": ["diff_truncated"] },
484 "then": { "required": ["diff"] }
485 }, {
486 "if": { "required": ["content_truncated"] },
487 "then": { "required": ["content"] }
488 }],
489 "additionalProperties": true
490 }
491 }
492 }
493 }, {
494 "if": {
495 "properties": {
496 "subscription_id": { "const": FILE_CHANGED_SUBSCRIPTION_ID_V1 }
497 },
498 "required": ["subscription_id"]
499 },
500 "then": {
501 "properties": {
502 "event_type": { "const": FILE_CHANGED_EVENT_TYPE_V1 }
503 }
504 }
505 }],
506 "additionalProperties": true
507 })
508}
509
510#[cfg(test)]
511mod tests {
512 use super::*;
513
514 fn fixture_event() -> ToolEventV1 {
515 ToolEventV1::file_changed(
516 ToolEventContextV1::bounded("session-1", "root-session-1", "Write", "call-1").unwrap(),
517 FileChangedV1::bounded("/workspace/zenith/src/lib.rs").unwrap(),
518 )
519 .unwrap()
520 }
521
522 #[test]
523 fn file_changed_wire_json_matches_golden() {
524 let json = serde_json::to_string(&fixture_event()).unwrap();
525 assert_eq!(
526 json,
527 include_str!("../tests/golden/tool_event_v1.file_changed.json").trim()
528 );
529 }
530
531 #[test]
532 fn schema_matches_golden() {
533 let expected: Value =
534 serde_json::from_str(include_str!("../tests/golden/tool_event_v1.schema.json"))
535 .unwrap();
536 assert_eq!(tool_event_v1_schema(), expected);
537 }
538
539 #[test]
540 fn golden_event_validates_against_public_schema() {
541 let schema = tool_event_v1_schema();
542 let validator = jsonschema::validator_for(&schema).unwrap();
543 let fixture: Value = serde_json::from_str(include_str!(
544 "../tests/golden/tool_event_v1.file_changed.json"
545 ))
546 .unwrap();
547 assert!(validator.validate(&fixture).is_ok());
548
549 let mut mismatched = fixture;
550 mismatched["subscription_id"] = json!("tool.future.v1");
551 assert!(validator.validate(&mismatched).is_err());
552 }
553
554 #[test]
555 fn metadata_only_host_projection_round_trips_and_validates_against_public_schema() {
556 let projection = crate::ProjectedToolEventV1::file_changed(
557 crate::ProjectedToolEventContextV1 {
558 session_id: "session-1".to_string(),
559 root_session_id: "root-session-1".to_string(),
560 tool_name: None,
561 tool_call_id: "call-1".to_string(),
562 },
563 crate::ProjectedFileChangedV1 {
564 path_redaction_reason: Some(
565 crate::TOOL_EVENT_PATH_REDACTION_PERMISSION_NOT_GRANTED.to_string(),
566 ),
567 ..crate::ProjectedFileChangedV1::default()
568 },
569 3,
570 );
571 let value = serde_json::to_value(&projection).unwrap();
572 assert!(value["context"].get("tool_name").is_none());
573 assert!(value["data"].get("path").is_none());
574 let validator = jsonschema::validator_for(&tool_event_v1_schema()).unwrap();
575 assert!(validator.validate(&value).is_ok());
576 assert_eq!(
577 serde_json::from_value::<crate::ProjectedToolEventV1>(value.clone()).unwrap(),
578 projection
579 );
580
581 let mut path_and_reason = value.clone();
582 path_and_reason["data"]["path"] = json!("/workspace/file.rs");
583 assert!(validator.validate(&path_and_reason).is_err());
584 let mut redacted_payload = value.clone();
585 redacted_payload["data"]["content"] = json!("must-not-be-accepted");
586 assert!(validator.validate(&redacted_payload).is_err());
587 let mut empty_data = value;
588 empty_data["data"] = json!({});
589 assert!(validator.validate(&empty_data).is_err());
590 }
591
592 #[test]
593 fn unknown_compatible_variant_and_extensions_round_trip() {
594 let raw = json!({
595 "schema_version": 1,
596 "event_type": "future_event",
597 "subscription_id": "tool.future_event.v1",
598 "context": {
599 "session_id": "session-1",
600 "root_session_id": "root-session-1",
601 "tool_name": "FutureTool",
602 "tool_call_id": "call-future",
603 "trace_hint": "kept"
604 },
605 "data": { "future": [1, 2, 3] },
606 "producer_hint": { "also": "kept" }
607 });
608 let decoded: ToolEventV1 = serde_json::from_value(raw.clone()).unwrap();
609
610 assert_eq!(decoded.event_type.as_str(), "future_event");
611 assert!(decoded.file_changed_data().is_none());
612 assert_eq!(serde_json::to_value(decoded).unwrap(), raw);
613 }
614
615 #[test]
616 fn required_and_size_bounds_are_explicit() {
617 assert_eq!(
618 ToolEventContextV1::bounded("", "root-session", "Write", "call").unwrap_err(),
619 ToolEventBuildError::EmptyField {
620 field: "context.session_id"
621 }
622 );
623 assert!(matches!(
624 FileChangedV1::bounded("x".repeat(MAX_TOOL_EVENT_PATH_BYTES + 1)),
625 Err(ToolEventBuildError::FieldTooLarge {
626 field: "data.path",
627 ..
628 })
629 ));
630 }
631
632 #[test]
633 fn string_bounds_count_exact_utf8_bytes() {
634 let exact = "é".repeat(MAX_TOOL_EVENT_PATH_BYTES / "é".len());
635 assert_eq!(exact.len(), MAX_TOOL_EVENT_PATH_BYTES);
636 assert!(FileChangedV1::bounded(exact).is_ok());
637
638 let over = "é".repeat(MAX_TOOL_EVENT_PATH_BYTES / "é".len() + 1);
639 assert_eq!(
640 FileChangedV1::bounded(over),
641 Err(ToolEventBuildError::FieldTooLarge {
642 field: "data.path",
643 actual: MAX_TOOL_EVENT_PATH_BYTES + "é".len(),
644 max: MAX_TOOL_EVENT_PATH_BYTES,
645 })
646 );
647
648 let schema = tool_event_v1_schema();
649 assert_eq!(schema["properties"]["data"]["type"], json!("object"));
650 assert_eq!(
651 schema["allOf"][0]["then"]["properties"]["data"]["properties"]["path"]
652 ["x-bamboo-maxUtf8Bytes"],
653 json!(MAX_TOOL_EVENT_PATH_BYTES)
654 );
655 assert!(
656 schema["allOf"][0]["then"]["properties"]["data"]["properties"]["path"]
657 .get("maxLength")
658 .is_none()
659 );
660 }
661
662 #[test]
663 fn total_wire_json_bound_is_enforced_after_field_bounds() {
664 let mut event = fixture_event();
665 event.extensions.insert(
666 "future_payload".to_string(),
667 json!("x".repeat(MAX_TOOL_EVENT_JSON_BYTES)),
668 );
669
670 assert!(matches!(
671 event.validate_bounds(),
672 Err(ToolEventBuildError::EventTooLarge {
673 actual,
674 max: MAX_TOOL_EVENT_JSON_BYTES,
675 }) if actual > MAX_TOOL_EVENT_JSON_BYTES
676 ));
677 }
678
679 #[test]
680 fn raw_constructor_does_not_serialize_unknown_payload_before_projection() {
681 let mut data = FileChangedV1::bounded("/workspace/file.rs").unwrap();
682 data.extensions.insert(
683 "future_secret".to_string(),
684 json!("secret".repeat(MAX_TOOL_EVENT_JSON_BYTES)),
685 );
686
687 let event = ToolEventV1::file_changed(
688 ToolEventContextV1::bounded("session", "root", "Write", "call").unwrap(),
689 data,
690 )
691 .expect("field-valid raw input is assembled without whole-event serialization");
692 assert!(matches!(
693 event.validate_bounds(),
694 Err(ToolEventBuildError::EventTooLarge {
695 actual,
696 max: MAX_TOOL_EVENT_JSON_BYTES,
697 }) if actual > MAX_TOOL_EVENT_JSON_BYTES
698 ));
699 }
700
701 #[test]
702 fn all_variants_require_object_data_and_known_identifiers_stay_paired() {
703 let mut event = fixture_event();
704 event.data = json!("not-an-object");
705 assert_eq!(
706 event.validate_bounds(),
707 Err(ToolEventBuildError::DataMustBeObject)
708 );
709
710 let mut event = fixture_event();
711 event.subscription_id = ToolEventSubscriptionId::new("tool.future.v1");
712 assert_eq!(
713 event.validate_bounds(),
714 Err(ToolEventBuildError::KnownVariantMismatch)
715 );
716
717 let mut event = fixture_event();
718 event.event_type = ToolEventTypeV1::new("future_event");
719 assert_eq!(
720 event.validate_bounds(),
721 Err(ToolEventBuildError::KnownVariantMismatch)
722 );
723
724 let mut event = fixture_event();
725 event.schema_version = TOOL_EVENT_V1_SCHEMA_VERSION + 1;
726 assert_eq!(
727 event.validate_bounds(),
728 Err(ToolEventBuildError::UnsupportedSchemaVersion {
729 actual: TOOL_EVENT_V1_SCHEMA_VERSION + 1,
730 supported: TOOL_EVENT_V1_SCHEMA_VERSION,
731 })
732 );
733 }
734
735 #[test]
736 fn flattened_extensions_cannot_shadow_reserved_wire_fields() {
737 let mut context = fixture_event().context;
738 context
739 .extensions
740 .insert("session_id".to_string(), json!("spoofed-session"));
741 assert_eq!(
742 context.validate_bounds(),
743 Err(ToolEventBuildError::ReservedExtensionKey {
744 location: "context",
745 key: "session_id".to_string(),
746 })
747 );
748
749 let mut data = FileChangedV1::bounded("/bounded/file.txt").unwrap();
750 data.extensions
751 .insert("path".to_string(), json!("/spoofed/file.txt"));
752 assert_eq!(
753 data.validate_bounds(),
754 Err(ToolEventBuildError::ReservedExtensionKey {
755 location: "data",
756 key: "path".to_string(),
757 })
758 );
759
760 let mut event = fixture_event();
761 event
762 .extensions
763 .insert("schema_version".to_string(), json!(999));
764 assert_eq!(
765 event.validate_bounds(),
766 Err(ToolEventBuildError::ReservedExtensionKey {
767 location: "envelope",
768 key: "schema_version".to_string(),
769 })
770 );
771 }
772}