1use crate::event_protocol::{run_event_envelope_v1, EventEnvelopeV1, EVENT_ENVELOPE_V1_VERSION};
9pub use crate::release::AGENT_PROTOCOL_V1;
10use crate::run::{RunEventPage, RunEventRecord, RunStatus};
11use crate::session_checkpoint::{SessionCheckpointDescriptorV1, SessionLogicalResumeEvidenceV1};
12use base64::Engine as _;
13use serde::{Deserialize, Serialize};
14use sha2::{Digest, Sha256};
15use thiserror::Error;
16
17pub const AGENT_PROTOCOL_MAX_ID_BYTES: usize = 256;
18pub const AGENT_PROTOCOL_MAX_REASON_BYTES: usize = 1_024;
19pub const AGENT_PROTOCOL_MAX_PROMPT_BYTES: usize = 64 * 1024;
20pub const AGENT_PROTOCOL_MAX_EVENT_TYPE_BYTES: usize = 128;
21pub const AGENT_PROTOCOL_MAX_EVENT_PAYLOAD_BYTES: usize = 64 * 1024;
22pub const AGENT_PROTOCOL_MAX_EVENT_METADATA_BYTES: usize = 16 * 1024;
23pub const AGENT_PROTOCOL_MAX_EVENT_RECORD_BYTES: usize = 64 * 1024;
24pub const AGENT_PROTOCOL_MAX_EVENTS_PER_PAGE: usize = 64;
25pub const AGENT_PROTOCOL_MAX_EVENT_PAGE_BYTES: usize = 6 * 1024 * 1024;
26pub const AGENT_PROTOCOL_MAX_CHANGE_SET_BYTES: usize = 4 * 1024 * 1024;
27pub const AGENT_PROTOCOL_MAX_CHANGE_SET_RESPONSE_BYTES: usize =
28 (AGENT_PROTOCOL_MAX_CHANGE_SET_BYTES * 4 / 3) + 128 * 1024;
29pub const AGENT_PROTOCOL_CHANGE_SET_FORMAT_V1: &str = "git_unified_diff_v1";
30pub const AGENT_PROTOCOL_CHANGE_SET_ENCODING_V1: &str = "base64";
31
32pub const AGENT_PROTOCOL_COMMAND_HTTP_PATH_V1: &str = "/v1/agent/commands";
34
35pub const AGENT_PROTOCOL_EVENT_PAGE_HTTP_PATH_V1: &str = "/v1/agent/events:page";
37
38pub const AGENT_PROTOCOL_CHANGE_SET_HTTP_PATH_V1: &str = "/v1/agent/changes";
40
41#[derive(Debug, Clone, PartialEq, Eq, Error)]
43pub enum AgentProtocolError {
44 #[error("unsupported A3S Code Agent protocol schema")]
45 UnsupportedSchema,
46 #[error("invalid A3S Code Agent protocol field: {0}")]
47 InvalidField(&'static str),
48 #[error("A3S Code Agent protocol identity or sequence does not match")]
49 IdentityMismatch,
50 #[error("A3S Code Agent protocol value exceeds its bounded encoding")]
51 Encoding,
52}
53
54impl AgentProtocolError {
55 pub const fn code(&self) -> &'static str {
57 match self {
58 Self::UnsupportedSchema => "a3s.code.agent_protocol.unsupported_schema",
59 Self::InvalidField(_) => "a3s.code.agent_protocol.invalid_field",
60 Self::IdentityMismatch => "a3s.code.agent_protocol.identity_mismatch",
61 Self::Encoding => "a3s.code.agent_protocol.encoding",
62 }
63 }
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(deny_unknown_fields)]
69pub struct AgentProtocolRunIdentityV1 {
70 pub schema: String,
71 pub protocol: String,
72 pub agent_release_identity: String,
73 pub session_id: String,
74 pub run_id: String,
75}
76
77impl AgentProtocolRunIdentityV1 {
78 pub const SCHEMA: &'static str = "a3s.code.agent-run-identity.v1";
79
80 pub fn validate(&self) -> Result<(), AgentProtocolError> {
81 validate_schema(&self.schema, Self::SCHEMA)?;
82 if self.protocol != AGENT_PROTOCOL_V1 {
83 return Err(AgentProtocolError::InvalidField("protocol"));
84 }
85 validate_lower_sha256("agent_release_identity", &self.agent_release_identity)?;
86 validate_id("session_id", &self.session_id)?;
87 validate_id("run_id", &self.run_id)
88 }
89
90 pub fn digest(&self) -> Result<String, AgentProtocolError> {
91 digest_validated(self, || self.validate())
92 }
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97#[serde(deny_unknown_fields)]
98pub struct AgentProtocolRunStartV1 {
99 pub schema: String,
100 pub request_id: String,
101 pub identity: AgentProtocolRunIdentityV1,
102 pub prompt: String,
103}
104
105impl AgentProtocolRunStartV1 {
106 pub const SCHEMA: &'static str = "a3s.code.agent-run-start.v1";
107
108 pub fn validate(&self) -> Result<(), AgentProtocolError> {
109 validate_schema(&self.schema, Self::SCHEMA)?;
110 validate_id("request_id", &self.request_id)?;
111 self.identity.validate()?;
112 if self.prompt.trim().is_empty()
113 || self.prompt.len() > AGENT_PROTOCOL_MAX_PROMPT_BYTES
114 || self.prompt.contains('\0')
115 {
116 return Err(AgentProtocolError::InvalidField("prompt"));
117 }
118 Ok(())
119 }
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
124#[serde(deny_unknown_fields)]
125pub struct AgentProtocolRunCancelV1 {
126 pub schema: String,
127 pub request_id: String,
128 pub identity: AgentProtocolRunIdentityV1,
129 pub reason: String,
130}
131
132impl AgentProtocolRunCancelV1 {
133 pub const SCHEMA: &'static str = "a3s.code.agent-run-cancel.v1";
134
135 pub fn validate(&self) -> Result<(), AgentProtocolError> {
136 validate_schema(&self.schema, Self::SCHEMA)?;
137 validate_id("request_id", &self.request_id)?;
138 self.identity.validate()?;
139 validate_single_line("reason", &self.reason, AGENT_PROTOCOL_MAX_REASON_BYTES)
140 }
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145#[serde(deny_unknown_fields)]
146pub struct AgentProtocolRunRecoverV1 {
147 pub schema: String,
148 pub request_id: String,
149 pub identity: AgentProtocolRunIdentityV1,
150 pub checkpoint_run_id: String,
151}
152
153impl AgentProtocolRunRecoverV1 {
154 pub const SCHEMA: &'static str = "a3s.code.agent-run-recover.v1";
155
156 pub fn validate(&self) -> Result<(), AgentProtocolError> {
157 validate_schema(&self.schema, Self::SCHEMA)?;
158 validate_id("request_id", &self.request_id)?;
159 self.identity.validate()?;
160 validate_id("checkpoint_run_id", &self.checkpoint_run_id)?;
161 if self.checkpoint_run_id == self.identity.run_id {
162 return Err(AgentProtocolError::InvalidField("checkpoint_run_id"));
163 }
164 Ok(())
165 }
166}
167
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
177#[serde(deny_unknown_fields)]
178pub struct AgentProtocolRunRecoverExactV1 {
179 pub schema: String,
180 pub request_id: String,
181 pub identity: AgentProtocolRunIdentityV1,
182 pub checkpoint: SessionCheckpointDescriptorV1,
183}
184
185impl AgentProtocolRunRecoverExactV1 {
186 pub const SCHEMA: &'static str = "a3s.code.agent-run-recover-exact.v1";
187
188 pub fn validate(&self) -> Result<(), AgentProtocolError> {
189 validate_schema(&self.schema, Self::SCHEMA)?;
190 validate_id("request_id", &self.request_id)?;
191 self.identity.validate()?;
192 self.checkpoint
193 .validate()
194 .map_err(|_| AgentProtocolError::InvalidField("checkpoint"))?;
195 let logical_resume = self.logical_resume()?;
196 if self.checkpoint.snapshot.session_id != self.identity.session_id
197 || logical_resume.session_id != self.identity.session_id
198 || logical_resume.source_run_id == self.identity.run_id
199 {
200 return Err(AgentProtocolError::InvalidField("checkpoint"));
201 }
202 Ok(())
203 }
204
205 pub fn logical_resume(&self) -> Result<&SessionLogicalResumeEvidenceV1, AgentProtocolError> {
206 self.checkpoint
207 .logical_resume
208 .as_ref()
209 .ok_or(AgentProtocolError::InvalidField("checkpoint"))
210 }
211
212 pub fn digest(&self) -> Result<String, AgentProtocolError> {
213 digest_validated(self, || self.validate())
214 }
215}
216
217#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
219#[serde(rename_all = "snake_case")]
220pub enum AgentProtocolCommandActionV1 {
221 Start,
222 Cancel,
223 Recover,
224}
225
226#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
228#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)]
229pub enum AgentProtocolCommandV1 {
230 Start { request: AgentProtocolRunStartV1 },
231 Cancel { request: AgentProtocolRunCancelV1 },
232 Recover { request: AgentProtocolRunRecoverV1 },
233}
234
235impl AgentProtocolCommandV1 {
236 pub const fn action(&self) -> AgentProtocolCommandActionV1 {
237 match self {
238 Self::Start { .. } => AgentProtocolCommandActionV1::Start,
239 Self::Cancel { .. } => AgentProtocolCommandActionV1::Cancel,
240 Self::Recover { .. } => AgentProtocolCommandActionV1::Recover,
241 }
242 }
243
244 pub fn request_id(&self) -> &str {
245 match self {
246 Self::Start { request } => &request.request_id,
247 Self::Cancel { request } => &request.request_id,
248 Self::Recover { request } => &request.request_id,
249 }
250 }
251
252 pub fn identity(&self) -> &AgentProtocolRunIdentityV1 {
253 match self {
254 Self::Start { request } => &request.identity,
255 Self::Cancel { request } => &request.identity,
256 Self::Recover { request } => &request.identity,
257 }
258 }
259
260 pub fn validate(&self) -> Result<(), AgentProtocolError> {
261 match self {
262 Self::Start { request } => request.validate(),
263 Self::Cancel { request } => request.validate(),
264 Self::Recover { request } => request.validate(),
265 }
266 }
267
268 pub fn digest(&self) -> Result<String, AgentProtocolError> {
269 digest_validated(self, || self.validate())
270 }
271}
272
273#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
275#[serde(rename_all = "snake_case")]
276pub enum AgentProtocolRunStateV1 {
277 Created,
278 Planning,
279 Executing,
280 Verifying,
281 Completed,
282 Failed,
283 Cancelled,
284}
285
286impl AgentProtocolRunStateV1 {
287 pub const fn as_str(self) -> &'static str {
288 match self {
289 Self::Created => "created",
290 Self::Planning => "planning",
291 Self::Executing => "executing",
292 Self::Verifying => "verifying",
293 Self::Completed => "completed",
294 Self::Failed => "failed",
295 Self::Cancelled => "cancelled",
296 }
297 }
298
299 pub const fn is_terminal(self) -> bool {
300 matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
301 }
302}
303
304impl From<RunStatus> for AgentProtocolRunStateV1 {
305 fn from(value: RunStatus) -> Self {
306 match value {
307 RunStatus::Created => Self::Created,
308 RunStatus::Planning => Self::Planning,
309 RunStatus::Executing => Self::Executing,
310 RunStatus::Verifying => Self::Verifying,
311 RunStatus::Completed => Self::Completed,
312 RunStatus::Failed => Self::Failed,
313 RunStatus::Cancelled => Self::Cancelled,
314 }
315 }
316}
317
318#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
320#[serde(deny_unknown_fields)]
321pub struct AgentProtocolCommandReceiptV1 {
322 pub schema: String,
323 pub action: AgentProtocolCommandActionV1,
324 pub request_id: String,
325 pub identity: AgentProtocolRunIdentityV1,
326 pub command_digest: String,
327 pub state: AgentProtocolRunStateV1,
328 pub latest_event_sequence_exclusive: u64,
329 pub observed_at_ms: u64,
330 pub replayed: bool,
331}
332
333impl AgentProtocolCommandReceiptV1 {
334 pub const SCHEMA: &'static str = "a3s.code.agent-command-receipt.v1";
335
336 pub fn validate(&self) -> Result<(), AgentProtocolError> {
337 validate_schema(&self.schema, Self::SCHEMA)?;
338 validate_id("request_id", &self.request_id)?;
339 self.identity.validate()?;
340 validate_lower_sha256("command_digest", &self.command_digest)?;
341 if self.observed_at_ms == 0 {
342 return Err(AgentProtocolError::InvalidField("observed_at_ms"));
343 }
344 Ok(())
345 }
346
347 pub fn validate_for(&self, command: &AgentProtocolCommandV1) -> Result<(), AgentProtocolError> {
348 command.validate()?;
349 self.validate()?;
350 if self.action != command.action()
351 || self.request_id != command.request_id()
352 || self.identity != *command.identity()
353 || self.command_digest != command.digest()?
354 {
355 return Err(AgentProtocolError::IdentityMismatch);
356 }
357 if self.action == AgentProtocolCommandActionV1::Cancel && !self.state.is_terminal() {
358 return Err(AgentProtocolError::InvalidField("state"));
359 }
360 Ok(())
361 }
362
363 pub fn validate_for_exact_recovery(
365 &self,
366 request: &AgentProtocolRunRecoverExactV1,
367 ) -> Result<(), AgentProtocolError> {
368 request.validate()?;
369 self.validate()?;
370 if self.action != AgentProtocolCommandActionV1::Recover
371 || self.request_id != request.request_id
372 || self.identity != request.identity
373 || self.command_digest != request.digest()?
374 {
375 return Err(AgentProtocolError::IdentityMismatch);
376 }
377 Ok(())
378 }
379}
380
381#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
383#[serde(deny_unknown_fields)]
384pub struct AgentProtocolEventRecordV1 {
385 pub sequence: u64,
386 pub occurred_at_ms: u64,
387 pub event: EventEnvelopeV1,
388}
389
390impl AgentProtocolEventRecordV1 {
391 pub fn from_run_event(
392 record: &RunEventRecord,
393 identity: &AgentProtocolRunIdentityV1,
394 ) -> Result<Self, AgentProtocolError> {
395 identity.validate()?;
396 let sequence = u64::try_from(record.sequence)
397 .map_err(|_| AgentProtocolError::InvalidField("sequence"))?;
398 let event = run_event_envelope_v1(record, &identity.run_id, &identity.session_id)
399 .map_err(|_| AgentProtocolError::Encoding)?;
400 let mut projected = Self {
401 sequence,
402 occurred_at_ms: record.timestamp_ms,
403 event,
404 };
405 if projected.validate_for(identity).is_ok() {
406 return Ok(projected);
407 }
408 bound_projected_event_record(&mut projected)?;
411 projected.validate_for(identity)?;
412 Ok(projected)
413 }
414
415 pub fn validate_for(
420 &self,
421 identity: &AgentProtocolRunIdentityV1,
422 ) -> Result<(), AgentProtocolError> {
423 if self.event.version != EVENT_ENVELOPE_V1_VERSION {
424 return Err(AgentProtocolError::InvalidField("event.version"));
425 }
426 validate_single_line(
427 "event.type",
428 &self.event.event_type,
429 AGENT_PROTOCOL_MAX_EVENT_TYPE_BYTES,
430 )?;
431 validate_json_size(
432 "event.payload",
433 &self.event.payload,
434 AGENT_PROTOCOL_MAX_EVENT_PAYLOAD_BYTES,
435 )?;
436 let metadata = self
437 .event
438 .metadata
439 .as_ref()
440 .ok_or(AgentProtocolError::InvalidField("event.metadata"))?;
441 validate_json_size(
442 "event.metadata",
443 metadata,
444 AGENT_PROTOCOL_MAX_EVENT_METADATA_BYTES,
445 )?;
446 let metadata = metadata
447 .as_object()
448 .ok_or(AgentProtocolError::InvalidField("event.metadata"))?;
449 let exact = metadata.get("session_id").and_then(|value| value.as_str())
450 == Some(identity.session_id.as_str())
451 && metadata.get("run_id").and_then(|value| value.as_str())
452 == Some(identity.run_id.as_str())
453 && metadata.get("sequence").and_then(|value| value.as_u64()) == Some(self.sequence)
454 && metadata
455 .get("timestamp_ms")
456 .and_then(|value| value.as_u64())
457 == Some(self.occurred_at_ms);
458 if !exact {
459 return Err(AgentProtocolError::IdentityMismatch);
460 }
461 let encoded = serde_json::to_vec(self).map_err(|_| AgentProtocolError::Encoding)?;
462 if encoded.len() > AGENT_PROTOCOL_MAX_EVENT_RECORD_BYTES {
463 return Err(AgentProtocolError::InvalidField("event"));
464 }
465 Ok(())
466 }
467}
468
469#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
471#[serde(deny_unknown_fields)]
472pub struct AgentProtocolEventPageRequestV1 {
473 pub schema: String,
474 pub identity: AgentProtocolRunIdentityV1,
475 pub after_event_sequence: Option<u64>,
476 pub limit: u16,
477}
478
479impl AgentProtocolEventPageRequestV1 {
480 pub const SCHEMA: &'static str = "a3s.code.agent-event-page-request.v1";
481
482 pub fn validate(&self) -> Result<(), AgentProtocolError> {
483 validate_schema(&self.schema, Self::SCHEMA)?;
484 self.identity.validate()?;
485 if self.limit == 0 || usize::from(self.limit) > AGENT_PROTOCOL_MAX_EVENTS_PER_PAGE {
486 return Err(AgentProtocolError::InvalidField("limit"));
487 }
488 Ok(())
489 }
490
491 pub fn digest(&self) -> Result<String, AgentProtocolError> {
492 digest_validated(self, || self.validate())
493 }
494}
495
496#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
498#[serde(deny_unknown_fields)]
499pub struct AgentProtocolEventPageV1 {
500 pub schema: String,
501 pub identity: AgentProtocolRunIdentityV1,
502 pub after_event_sequence: Option<u64>,
503 pub first_available_sequence: Option<u64>,
504 pub latest_sequence_exclusive: u64,
505 pub next_after_event_sequence: Option<u64>,
506 pub state: AgentProtocolRunStateV1,
507 pub observed_at_ms: u64,
508 pub retention_gap: bool,
509 pub has_more: bool,
510 pub events: Vec<AgentProtocolEventRecordV1>,
511}
512
513impl AgentProtocolEventPageV1 {
514 pub const SCHEMA: &'static str = "a3s.code.agent-event-page.v1";
515
516 pub fn from_run_page(
517 identity: AgentProtocolRunIdentityV1,
518 state: RunStatus,
519 observed_at_ms: u64,
520 after_event_sequence: Option<usize>,
521 page: &RunEventPage,
522 ) -> Result<Self, AgentProtocolError> {
523 identity.validate()?;
524 let convert = |value: usize| {
525 u64::try_from(value).map_err(|_| AgentProtocolError::InvalidField("sequence"))
526 };
527 let events = page
528 .events
529 .iter()
530 .map(|record| AgentProtocolEventRecordV1::from_run_event(record, &identity))
531 .collect::<Result<Vec<_>, _>>()?;
532 let projected = Self {
533 schema: Self::SCHEMA.into(),
534 identity,
535 after_event_sequence: after_event_sequence.map(convert).transpose()?,
536 first_available_sequence: page.first_available_sequence.map(convert).transpose()?,
537 latest_sequence_exclusive: convert(page.latest_sequence_exclusive)?,
538 next_after_event_sequence: page.next_after_sequence.map(convert).transpose()?,
539 state: state.into(),
540 observed_at_ms,
541 retention_gap: page.retention_gap,
542 has_more: page.has_more,
543 events,
544 };
545 projected.validate()?;
546 Ok(projected)
547 }
548
549 pub fn validate(&self) -> Result<(), AgentProtocolError> {
550 validate_schema(&self.schema, Self::SCHEMA)?;
551 self.identity.validate()?;
552 if self.events.len() > AGENT_PROTOCOL_MAX_EVENTS_PER_PAGE {
553 return Err(AgentProtocolError::InvalidField("events"));
554 }
555 if self
556 .after_event_sequence
557 .is_some_and(|sequence| sequence >= self.latest_sequence_exclusive)
558 {
559 return Err(AgentProtocolError::InvalidField("after_event_sequence"));
560 }
561 if self
562 .first_available_sequence
563 .is_some_and(|sequence| sequence >= self.latest_sequence_exclusive)
564 {
565 return Err(AgentProtocolError::InvalidField("first_available_sequence"));
566 }
567
568 let requested_start = self
569 .after_event_sequence
570 .map(|sequence| sequence.saturating_add(1))
571 .unwrap_or(0);
572 let expected_gap = requested_start < self.latest_sequence_exclusive
573 && self
574 .first_available_sequence
575 .is_none_or(|first| requested_start < first);
576 if self.retention_gap != expected_gap {
577 return Err(AgentProtocolError::InvalidField("retention_gap"));
578 }
579
580 let mut previous: Option<(u64, u64)> = None;
581 for event in &self.events {
582 event.validate_for(&self.identity)?;
583 if event.occurred_at_ms > self.observed_at_ms
584 || previous.is_some_and(|(sequence, timestamp)| {
585 event.sequence != sequence.saturating_add(1) || event.occurred_at_ms < timestamp
586 })
587 || event.sequence >= self.latest_sequence_exclusive
588 {
589 return Err(AgentProtocolError::InvalidField("events"));
590 }
591 previous = Some((event.sequence, event.occurred_at_ms));
592 }
593
594 if let Some(first) = self.events.first() {
595 if (!self.retention_gap && first.sequence != requested_start)
596 || (self.retention_gap && self.first_available_sequence != Some(first.sequence))
597 || self
598 .first_available_sequence
599 .is_some_and(|available| first.sequence < available)
600 {
601 return Err(AgentProtocolError::InvalidField("events"));
602 }
603 } else if self.retention_gap && self.first_available_sequence.is_some() {
604 return Err(AgentProtocolError::InvalidField("events"));
605 }
606 let expected_next = self
607 .events
608 .last()
609 .map(|event| event.sequence)
610 .or(self.after_event_sequence);
611 if self.next_after_event_sequence != expected_next {
612 return Err(AgentProtocolError::InvalidField(
613 "next_after_event_sequence",
614 ));
615 }
616 if self.has_more {
617 if self.events.last().is_none_or(|event| {
618 event.sequence.saturating_add(1) >= self.latest_sequence_exclusive
619 }) {
620 return Err(AgentProtocolError::InvalidField("has_more"));
621 }
622 } else if let Some(last) = self.events.last() {
623 if last.sequence.saturating_add(1) != self.latest_sequence_exclusive {
624 return Err(AgentProtocolError::InvalidField("has_more"));
625 }
626 }
627 let encoded = serde_json::to_vec(self).map_err(|_| AgentProtocolError::Encoding)?;
628 if encoded.len() > AGENT_PROTOCOL_MAX_EVENT_PAGE_BYTES {
629 return Err(AgentProtocolError::InvalidField("events"));
630 }
631 Ok(())
632 }
633
634 pub fn first_sequence(&self) -> Option<u64> {
635 self.events.first().map(|event| event.sequence)
636 }
637
638 pub fn last_sequence(&self) -> Option<u64> {
639 self.events.last().map(|event| event.sequence)
640 }
641
642 pub fn digest(&self) -> Result<String, AgentProtocolError> {
643 digest_validated(self, || self.validate())
644 }
645}
646
647#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
649#[serde(deny_unknown_fields)]
650pub struct AgentProtocolChangeSetRequestV1 {
651 pub schema: String,
652 pub identity: AgentProtocolRunIdentityV1,
653}
654
655impl AgentProtocolChangeSetRequestV1 {
656 pub const SCHEMA: &'static str = "a3s.code.agent-change-set-request.v1";
657
658 pub fn validate(&self) -> Result<(), AgentProtocolError> {
659 validate_schema(&self.schema, Self::SCHEMA)?;
660 self.identity.validate()
661 }
662
663 pub fn digest(&self) -> Result<String, AgentProtocolError> {
664 digest_validated(self, || self.validate())
665 }
666}
667
668#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
675#[serde(deny_unknown_fields)]
676pub struct AgentProtocolChangeSetV1 {
677 pub schema: String,
678 pub identity: AgentProtocolRunIdentityV1,
679 pub state: AgentProtocolRunStateV1,
680 pub format: String,
681 pub encoding: String,
682 pub base_tree: String,
683 pub result_tree: String,
684 pub patch_digest: String,
685 pub patch_bytes: u64,
686 pub patch_base64: String,
687 pub observed_at_ms: u64,
688}
689
690impl AgentProtocolChangeSetV1 {
691 pub const SCHEMA: &'static str = "a3s.code.agent-change-set.v1";
692
693 pub fn validate(&self) -> Result<(), AgentProtocolError> {
694 validate_schema(&self.schema, Self::SCHEMA)?;
695 self.identity.validate()?;
696 if !self.state.is_terminal() {
697 return Err(AgentProtocolError::InvalidField("state"));
698 }
699 if self.format != AGENT_PROTOCOL_CHANGE_SET_FORMAT_V1 {
700 return Err(AgentProtocolError::InvalidField("format"));
701 }
702 if self.encoding != AGENT_PROTOCOL_CHANGE_SET_ENCODING_V1 {
703 return Err(AgentProtocolError::InvalidField("encoding"));
704 }
705 validate_git_tree("base_tree", &self.base_tree)?;
706 validate_git_tree("result_tree", &self.result_tree)?;
707 validate_lower_sha256("patch_digest", &self.patch_digest)?;
708 let declared_bytes = usize::try_from(self.patch_bytes)
709 .map_err(|_| AgentProtocolError::InvalidField("patch_bytes"))?;
710 let patch = base64::engine::general_purpose::STANDARD
711 .decode(&self.patch_base64)
712 .map_err(|_| AgentProtocolError::InvalidField("patch_base64"))?;
713 if declared_bytes != patch.len()
714 || declared_bytes > AGENT_PROTOCOL_MAX_CHANGE_SET_BYTES
715 || self.patch_digest != format!("sha256:{:x}", Sha256::digest(&patch))
716 || self.observed_at_ms == 0
717 {
718 return Err(AgentProtocolError::InvalidField("patch_base64"));
719 }
720 let encoded = serde_json::to_vec(self).map_err(|_| AgentProtocolError::Encoding)?;
721 if encoded.len() > AGENT_PROTOCOL_MAX_CHANGE_SET_RESPONSE_BYTES {
722 return Err(AgentProtocolError::InvalidField("patch_base64"));
723 }
724 Ok(())
725 }
726
727 pub fn digest(&self) -> Result<String, AgentProtocolError> {
728 digest_validated(self, || self.validate())
729 }
730}
731
732fn validate_schema(value: &str, expected: &str) -> Result<(), AgentProtocolError> {
733 if value == expected {
734 Ok(())
735 } else {
736 Err(AgentProtocolError::UnsupportedSchema)
737 }
738}
739
740fn validate_id(field: &'static str, value: &str) -> Result<(), AgentProtocolError> {
741 validate_single_line(field, value, AGENT_PROTOCOL_MAX_ID_BYTES)
742}
743
744fn validate_single_line(
745 field: &'static str,
746 value: &str,
747 max: usize,
748) -> Result<(), AgentProtocolError> {
749 if value.trim().is_empty()
750 || value.len() > max
751 || value.contains('\0')
752 || value.contains(['\r', '\n'])
753 {
754 Err(AgentProtocolError::InvalidField(field))
755 } else {
756 Ok(())
757 }
758}
759
760pub(crate) fn validate_lower_sha256(
761 field: &'static str,
762 value: &str,
763) -> Result<(), AgentProtocolError> {
764 let valid = value.strip_prefix("sha256:").is_some_and(|hex| {
765 hex.len() == 64
766 && hex
767 .bytes()
768 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
769 });
770 if valid {
771 Ok(())
772 } else {
773 Err(AgentProtocolError::InvalidField(field))
774 }
775}
776
777fn validate_git_tree(field: &'static str, value: &str) -> Result<(), AgentProtocolError> {
778 let valid = value.strip_prefix("git-tree:").is_some_and(|hex| {
779 matches!(hex.len(), 40 | 64)
780 && hex
781 .bytes()
782 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
783 });
784 if valid {
785 Ok(())
786 } else {
787 Err(AgentProtocolError::InvalidField(field))
788 }
789}
790
791fn validate_json_size(
792 field: &'static str,
793 value: &serde_json::Value,
794 max: usize,
795) -> Result<(), AgentProtocolError> {
796 let encoded = serde_json::to_vec(value).map_err(|_| AgentProtocolError::Encoding)?;
797 if encoded.len() > max {
798 Err(AgentProtocolError::InvalidField(field))
799 } else {
800 Ok(())
801 }
802}
803
804const AGENT_PROTOCOL_PAYLOAD_TRUNCATION_MARK: &str = "\n…[a3s.code.agent-protocol.truncated]";
805
806fn bound_projected_event_record(
811 projected: &mut AgentProtocolEventRecordV1,
812) -> Result<(), AgentProtocolError> {
813 let original_payload = projected.event.payload.clone();
814 let original_digest = format!(
815 "sha256:{:x}",
816 Sha256::digest(
817 serde_json::to_vec(&original_payload).map_err(|_| AgentProtocolError::Encoding)?
818 )
819 );
820
821 for _ in 0..128 {
822 if projected_record_fits_limits(projected) {
823 return Ok(());
824 }
825 let payload_too_big = validate_json_size(
826 "event.payload",
827 &projected.event.payload,
828 AGENT_PROTOCOL_MAX_EVENT_PAYLOAD_BYTES,
829 )
830 .is_err();
831 if payload_too_big {
832 if !shrink_largest_json_string(
833 &mut projected.event.payload,
834 AGENT_PROTOCOL_PAYLOAD_TRUNCATION_MARK,
835 ) {
836 projected.event.payload =
837 bounded_event_payload_stub(&original_payload, &original_digest)?;
838 }
839 continue;
840 }
841 if let Some(metadata) = projected.event.metadata.as_mut() {
842 if !shrink_largest_json_string_excluding(
843 metadata,
844 AGENT_PROTOCOL_PAYLOAD_TRUNCATION_MARK,
845 &["session_id", "run_id", "sequence", "timestamp_ms"],
846 ) {
847 if let Some(obj) = metadata.as_object_mut() {
849 obj.retain(|key, _| {
850 matches!(
851 key.as_str(),
852 "session_id" | "run_id" | "sequence" | "timestamp_ms"
853 )
854 });
855 }
856 }
857 continue;
858 }
859 projected.event.payload = bounded_event_payload_stub(&original_payload, &original_digest)?;
860 }
861
862 if projected_record_fits_limits(projected) {
863 Ok(())
864 } else {
865 projected.event.payload = bounded_event_payload_stub(&original_payload, &original_digest)?;
866 if projected_record_fits_limits(projected) {
867 Ok(())
868 } else {
869 Err(AgentProtocolError::InvalidField("event"))
870 }
871 }
872}
873
874fn projected_record_fits_limits(projected: &AgentProtocolEventRecordV1) -> bool {
875 validate_json_size(
876 "event.payload",
877 &projected.event.payload,
878 AGENT_PROTOCOL_MAX_EVENT_PAYLOAD_BYTES,
879 )
880 .is_ok()
881 && projected
882 .event
883 .metadata
884 .as_ref()
885 .map(|metadata| {
886 validate_json_size(
887 "event.metadata",
888 metadata,
889 AGENT_PROTOCOL_MAX_EVENT_METADATA_BYTES,
890 )
891 .is_ok()
892 })
893 .unwrap_or(true)
894 && serde_json::to_vec(projected)
895 .map(|encoded| encoded.len() <= AGENT_PROTOCOL_MAX_EVENT_RECORD_BYTES)
896 .unwrap_or(false)
897}
898
899fn bounded_event_payload_stub(
900 original: &serde_json::Value,
901 original_digest: &str,
902) -> Result<serde_json::Value, AgentProtocolError> {
903 let mut stub = serde_json::json!({
904 "bounded": true,
905 "reason": "agent_protocol_event_payload_limit",
906 "original_payload_sha256": original_digest,
907 });
908 if let Some(obj) = original.as_object() {
909 for key in ["id", "name", "exit_code", "tool_id", "tool_name", "turn"] {
910 if let Some(value) = obj.get(key) {
911 let encoded =
912 serde_json::to_vec(value).map_err(|_| AgentProtocolError::Encoding)?;
913 if encoded.len() <= 512 {
914 stub[key] = value.clone();
915 }
916 }
917 }
918 }
919 Ok(stub)
920}
921
922fn shrink_largest_json_string(value: &mut serde_json::Value, mark: &str) -> bool {
923 shrink_largest_json_string_excluding(value, mark, &[])
924}
925
926fn shrink_largest_json_string_excluding(
927 value: &mut serde_json::Value,
928 mark: &str,
929 excluded_object_keys: &[&str],
930) -> bool {
931 let mut largest = 0usize;
932 largest_string_len(value, excluded_object_keys, &mut largest);
933 if largest == 0 {
934 return false;
935 }
936 shrink_first_string_of_len(value, largest, mark, excluded_object_keys)
937}
938
939fn largest_string_len(
940 value: &serde_json::Value,
941 excluded_object_keys: &[&str],
942 largest: &mut usize,
943) {
944 match value {
945 serde_json::Value::String(text) => {
946 *largest = (*largest).max(text.len());
947 }
948 serde_json::Value::Array(items) => {
949 for item in items {
950 largest_string_len(item, excluded_object_keys, largest);
951 }
952 }
953 serde_json::Value::Object(map) => {
954 for (key, item) in map {
955 if excluded_object_keys.iter().any(|excluded| *excluded == key) {
956 continue;
957 }
958 largest_string_len(item, excluded_object_keys, largest);
959 }
960 }
961 _ => {}
962 }
963}
964
965fn shrink_first_string_of_len(
966 value: &mut serde_json::Value,
967 target_len: usize,
968 mark: &str,
969 excluded_object_keys: &[&str],
970) -> bool {
971 match value {
972 serde_json::Value::String(text) if text.len() == target_len => {
973 let keep = (target_len / 2).min(target_len.saturating_sub(mark.len()));
976 let boundary = crate::text::truncate_utf8(text, keep).len();
977 text.truncate(boundary);
978 text.push_str(mark);
979 true
980 }
981 serde_json::Value::Array(items) => {
982 for item in items {
983 if shrink_first_string_of_len(item, target_len, mark, excluded_object_keys) {
984 return true;
985 }
986 }
987 false
988 }
989 serde_json::Value::Object(map) => {
990 for (key, item) in map.iter_mut() {
991 if excluded_object_keys.iter().any(|excluded| *excluded == key) {
992 continue;
993 }
994 if shrink_first_string_of_len(item, target_len, mark, excluded_object_keys) {
995 return true;
996 }
997 }
998 false
999 }
1000 _ => false,
1001 }
1002}
1003
1004fn digest_validated<T: Serialize>(
1005 value: &T,
1006 validate: impl FnOnce() -> Result<(), AgentProtocolError>,
1007) -> Result<String, AgentProtocolError> {
1008 validate()?;
1009 let encoded = serde_json::to_vec(value).map_err(|_| AgentProtocolError::Encoding)?;
1010 Ok(format!("sha256:{:x}", Sha256::digest(encoded)))
1011}
1012
1013#[cfg(test)]
1014mod tests {
1015 use super::*;
1016 use crate::agent::AgentEvent;
1017 use serde_json::json;
1018
1019 fn identity() -> AgentProtocolRunIdentityV1 {
1020 AgentProtocolRunIdentityV1 {
1021 schema: AgentProtocolRunIdentityV1::SCHEMA.into(),
1022 protocol: AGENT_PROTOCOL_V1.into(),
1023 agent_release_identity: format!("sha256:{}", "a".repeat(64)),
1024 session_id: "conversation-bound-meta".into(),
1025 run_id: "run-bound-meta".into(),
1026 }
1027 }
1028
1029 #[test]
1030 fn bound_projected_event_record_strips_oversized_non_identity_metadata() {
1031 let identity = identity();
1032 let mut projected = AgentProtocolEventRecordV1 {
1033 sequence: 0,
1034 occurred_at_ms: 1,
1035 event: EventEnvelopeV1::new("text_delta", json!({"text": "ok"})).with_metadata(json!({
1036 "session_id": identity.session_id,
1037 "run_id": identity.run_id,
1038 "sequence": 0u64,
1039 "timestamp_ms": 1u64,
1040 "noise": "n".repeat(AGENT_PROTOCOL_MAX_EVENT_METADATA_BYTES),
1041 })),
1042 };
1043
1044 bound_projected_event_record(&mut projected).expect("metadata must shrink");
1045 projected
1046 .validate_for(&identity)
1047 .expect("bounded metadata record must validate");
1048 let metadata = projected
1049 .event
1050 .metadata
1051 .as_ref()
1052 .and_then(|value| value.as_object())
1053 .expect("metadata object");
1054 assert_eq!(
1055 metadata.get("session_id").and_then(|value| value.as_str()),
1056 Some(identity.session_id.as_str())
1057 );
1058 assert_eq!(
1059 metadata.get("run_id").and_then(|value| value.as_str()),
1060 Some(identity.run_id.as_str())
1061 );
1062 let noise = metadata
1063 .get("noise")
1064 .and_then(|value| value.as_str())
1065 .unwrap_or("");
1066 assert!(
1067 noise.len() < AGENT_PROTOCOL_MAX_EVENT_METADATA_BYTES,
1068 "non-identity metadata must shrink below the protocol bound"
1069 );
1070 assert!(
1071 noise.ends_with(AGENT_PROTOCOL_PAYLOAD_TRUNCATION_MARK)
1072 || !metadata.contains_key("noise"),
1073 "oversized metadata must truncate or drop non-identity keys"
1074 );
1075 }
1076
1077 #[test]
1078 fn from_run_page_projects_oversized_tool_end_instead_of_400() {
1079 let identity = identity();
1080 let oversized = "x".repeat(AGENT_PROTOCOL_MAX_EVENT_PAYLOAD_BYTES + 8_192);
1081 let page = RunEventPage {
1082 events: vec![RunEventRecord {
1083 sequence: 0,
1084 timestamp_ms: 1,
1085 event: AgentEvent::ToolEnd {
1086 id: "tool-1".into(),
1087 name: "read".into(),
1088 args: None,
1089 exit_code: 0,
1090 output: oversized,
1091 metadata: None,
1092 error_kind: None,
1093 },
1094 }],
1095 first_available_sequence: Some(0),
1096 latest_sequence_exclusive: 1,
1097 next_after_sequence: Some(0),
1098 retention_gap: false,
1099 has_more: false,
1100 };
1101
1102 let projected = AgentProtocolEventPageV1::from_run_page(
1103 identity.clone(),
1104 RunStatus::Completed,
1105 1,
1106 None,
1107 &page,
1108 )
1109 .expect("oversized tool_end must still project a page");
1110 projected.validate().expect("projected page must validate");
1111 assert_eq!(projected.events.len(), 1);
1112 assert_eq!(projected.events[0].event.event_type, "tool_end");
1113 let payload = &projected.events[0].event.payload;
1114 let encoded = serde_json::to_vec(payload).expect("encode payload");
1115 assert!(
1116 encoded.len() <= AGENT_PROTOCOL_MAX_EVENT_PAYLOAD_BYTES,
1117 "bounded payload must fit protocol limit (got {})",
1118 encoded.len()
1119 );
1120 assert_eq!(
1121 payload.get("name").and_then(|value| value.as_str()),
1122 Some("read"),
1123 "identity fields must survive bounding"
1124 );
1125 }
1126
1127 #[tokio::test]
1128 async fn protocol_page_matches_observability_seq_and_marks_truncation() {
1129 let store = crate::run::InMemoryRunStore::new();
1130 let run = store.create_run("session-trunc", "prompt").await;
1131 let oversized = format!(
1132 "TRUNC-SRC-91{}",
1133 "x".repeat(AGENT_PROTOCOL_MAX_EVENT_PAYLOAD_BYTES)
1134 );
1135 store
1136 .record_event(
1137 &run.id,
1138 AgentEvent::ToolEnd {
1139 id: "tool-91".into(),
1140 name: "read".into(),
1141 args: None,
1142 exit_code: 0,
1143 output: oversized.clone(),
1144 metadata: None,
1145 error_kind: None,
1146 },
1147 )
1148 .await
1149 .expect("record oversized tool end");
1150 let observed = store
1151 .event_page(&run.id, None, 16)
1152 .await
1153 .expect("observability page");
1154 let snapshot = store.snapshot(&run.id).await.expect("snapshot");
1155 let projected = AgentProtocolEventPageV1::from_run_page(
1156 identity(),
1157 snapshot.status,
1158 snapshot.updated_at_ms,
1159 None,
1160 &observed,
1161 )
1162 .expect("protocol page");
1163
1164 assert_eq!(
1165 projected.first_available_sequence,
1166 observed
1167 .first_available_sequence
1168 .map(|sequence| u64::try_from(sequence).unwrap())
1169 );
1170 assert_eq!(
1171 projected.latest_sequence_exclusive,
1172 u64::try_from(observed.latest_sequence_exclusive).unwrap()
1173 );
1174 assert_eq!(
1175 projected.next_after_event_sequence,
1176 observed
1177 .next_after_sequence
1178 .map(|sequence| u64::try_from(sequence).unwrap())
1179 );
1180 assert_eq!(projected.retention_gap, observed.retention_gap);
1181 assert_eq!(projected.has_more, observed.has_more);
1182 assert_eq!(projected.events.len(), observed.events.len());
1183 assert_eq!(
1184 projected.events[0].sequence,
1185 u64::try_from(observed.events[0].sequence).unwrap()
1186 );
1187
1188 let AgentEvent::ToolEnd { output, .. } = &observed.events[0].event else {
1189 panic!("observability page dropped the tool end");
1190 };
1191 assert_eq!(output, &oversized);
1192 assert!(
1193 !output.contains(AGENT_PROTOCOL_PAYLOAD_TRUNCATION_MARK),
1194 "the retained page must keep the original tool output"
1195 );
1196 let output = projected.events[0].event.payload["output"]
1197 .as_str()
1198 .expect("protocol tool_end keeps an output string");
1199 assert!(
1200 output.ends_with(AGENT_PROTOCOL_PAYLOAD_TRUNCATION_MARK),
1201 "protocol page must end the oversized output with the truncation mark"
1202 );
1203 assert!(output.len() < oversized.len());
1204 assert!(!output.contains(&oversized));
1205 }
1206
1207 #[test]
1208 fn protocol_error_codes_are_stable() {
1209 assert_eq!(
1210 AgentProtocolError::UnsupportedSchema.code(),
1211 "a3s.code.agent_protocol.unsupported_schema"
1212 );
1213 assert_eq!(
1214 AgentProtocolError::InvalidField("prompt").code(),
1215 "a3s.code.agent_protocol.invalid_field"
1216 );
1217 assert_eq!(
1218 AgentProtocolError::IdentityMismatch.code(),
1219 "a3s.code.agent_protocol.identity_mismatch"
1220 );
1221 assert_eq!(
1222 AgentProtocolError::Encoding.code(),
1223 "a3s.code.agent_protocol.encoding"
1224 );
1225 }
1226
1227 #[test]
1228 fn identity_rejects_foreign_protocol_and_digests_when_valid() {
1229 let mut bad = identity();
1230 bad.protocol = "a3s.code.agent.v0".into();
1231 assert_eq!(
1232 bad.validate(),
1233 Err(AgentProtocolError::InvalidField("protocol"))
1234 );
1235 let good = identity();
1236 let digest = good.digest().expect("valid identity digests");
1237 assert!(digest.starts_with("sha256:"));
1238 }
1239
1240 #[test]
1241 fn event_page_validation_rejects_inconsistent_cursors_and_flags() {
1242 let identity = identity();
1243 let make_event = |sequence: u64, occurred_at_ms: u64| AgentProtocolEventRecordV1 {
1244 sequence,
1245 occurred_at_ms,
1246 event: EventEnvelopeV1::new("text_delta", json!({"text": "ok"})).with_metadata(json!({
1247 "session_id": identity.session_id,
1248 "run_id": identity.run_id,
1249 "sequence": sequence,
1250 "timestamp_ms": occurred_at_ms,
1251 })),
1252 };
1253 let mut page = AgentProtocolEventPageV1 {
1254 schema: AgentProtocolEventPageV1::SCHEMA.into(),
1255 identity: identity.clone(),
1256 after_event_sequence: None,
1257 first_available_sequence: Some(0),
1258 latest_sequence_exclusive: 1,
1259 next_after_event_sequence: Some(0),
1260 state: AgentProtocolRunStateV1::Completed,
1261 observed_at_ms: 10,
1262 retention_gap: false,
1263 has_more: false,
1264 events: vec![make_event(0, 10)],
1265 };
1266 page.validate()
1267 .expect("baseline page with metadata validates");
1268 assert_eq!(page.first_sequence(), Some(0));
1269 assert_eq!(page.last_sequence(), Some(0));
1270 assert!(page.digest().expect("digest").starts_with("sha256:"));
1271
1272 page.after_event_sequence = Some(1);
1274 assert_eq!(
1275 page.validate(),
1276 Err(AgentProtocolError::InvalidField("after_event_sequence"))
1277 );
1278
1279 page.after_event_sequence = None;
1280 page.first_available_sequence = Some(1);
1281 assert_eq!(
1282 page.validate(),
1283 Err(AgentProtocolError::InvalidField("first_available_sequence"))
1284 );
1285
1286 page.first_available_sequence = Some(0);
1287 page.retention_gap = true;
1288 assert_eq!(
1289 page.validate(),
1290 Err(AgentProtocolError::InvalidField("retention_gap"))
1291 );
1292
1293 page.retention_gap = false;
1294 page.next_after_event_sequence = Some(9);
1295 assert_eq!(
1296 page.validate(),
1297 Err(AgentProtocolError::InvalidField(
1298 "next_after_event_sequence"
1299 ))
1300 );
1301
1302 page.next_after_event_sequence = Some(0);
1303 page.has_more = true;
1304 assert_eq!(
1305 page.validate(),
1306 Err(AgentProtocolError::InvalidField("has_more"))
1307 );
1308
1309 page.has_more = false;
1310 page.latest_sequence_exclusive = 3;
1311 assert_eq!(
1312 page.validate(),
1313 Err(AgentProtocolError::InvalidField("has_more"))
1314 );
1315
1316 page.latest_sequence_exclusive = 3;
1318 page.after_event_sequence = None;
1319 page.first_available_sequence = Some(0);
1320 page.next_after_event_sequence = Some(1);
1321 page.events = vec![make_event(0, 1), make_event(2, 2)];
1322 assert_eq!(
1323 page.validate(),
1324 Err(AgentProtocolError::InvalidField("events"))
1325 );
1326
1327 page.events.clear();
1330 page.after_event_sequence = None;
1331 page.first_available_sequence = Some(1);
1332 page.latest_sequence_exclusive = 2;
1333 page.next_after_event_sequence = None;
1334 page.retention_gap = true;
1335 page.has_more = false;
1336 assert_eq!(
1337 page.validate(),
1338 Err(AgentProtocolError::InvalidField("events"))
1339 );
1340 }
1341
1342 #[test]
1343 fn shrink_helpers_walk_arrays_and_objects() {
1344 let mut value = json!({
1345 "keep": "identity",
1346 "items": ["short", "this-string-is-long-enough-to-shrink-aaaaaaaa"],
1347 "nested": {"noise": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}
1348 });
1349 assert!(shrink_largest_json_string_excluding(
1350 &mut value,
1351 AGENT_PROTOCOL_PAYLOAD_TRUNCATION_MARK,
1352 &["keep"],
1353 ));
1354 let mutated = value.to_string();
1355 assert!(
1356 mutated.contains("a3s.code.agent-protocol.truncated"),
1357 "expected truncation mark in {mutated}"
1358 );
1359
1360 let mut empty = json!({"n": 1, "b": true, "z": null});
1361 assert!(!shrink_largest_json_string(
1362 &mut empty,
1363 AGENT_PROTOCOL_PAYLOAD_TRUNCATION_MARK
1364 ));
1365 }
1366
1367 #[test]
1368 fn bounded_event_payload_stub_preserves_small_identity_fields() {
1369 let original = json!({
1370 "id": "tool-1",
1371 "name": "read",
1372 "exit_code": 0,
1373 "tool_id": "t",
1374 "tool_name": "read",
1375 "turn": 2,
1376 "huge": "x".repeat(1024),
1377 });
1378 let digest = format!("sha256:{}", "ab".repeat(32));
1379 let stub = bounded_event_payload_stub(&original, &digest).expect("stub");
1380 assert_eq!(stub["bounded"], true);
1381 assert_eq!(stub["original_payload_sha256"], digest);
1382 assert_eq!(stub["id"], "tool-1");
1383 assert_eq!(stub["name"], "read");
1384 assert!(stub.get("huge").is_none());
1385 }
1386
1387 #[test]
1388 fn bound_projected_event_record_falls_back_to_stub_for_non_string_payload() {
1389 let identity = identity();
1390 let mut projected = AgentProtocolEventRecordV1 {
1391 sequence: 0,
1392 occurred_at_ms: 1,
1393 event: EventEnvelopeV1::new(
1394 "tool_end",
1395 json!({
1396 "id": "tool-1",
1397 "name": "read",
1398 "exit_code": 0,
1399 "blob": {"n": 1},
1400 "pads": (0..80).map(|i| format!("pad-{i}-{}", "z".repeat(512))).collect::<Vec<_>>(),
1401 }),
1402 )
1403 .with_metadata(json!({
1404 "session_id": identity.session_id,
1405 "run_id": identity.run_id,
1406 "sequence": 0u64,
1407 "timestamp_ms": 1u64,
1408 "extra": (0..40).map(|i| format!("meta-{i}-{}", "m".repeat(256))).collect::<Vec<_>>(),
1409 })),
1410 };
1411 bound_projected_event_record(&mut projected).expect("must bound");
1412 projected
1413 .validate_for(&identity)
1414 .expect("bounded record validates");
1415 }
1416
1417 #[test]
1418 fn recover_rejects_checkpoint_run_id_equal_to_target_run() {
1419 let identity = identity();
1420 let recover = AgentProtocolRunRecoverV1 {
1421 schema: AgentProtocolRunRecoverV1::SCHEMA.into(),
1422 request_id: "req-recover".into(),
1423 identity: identity.clone(),
1424 checkpoint_run_id: identity.run_id.clone(),
1425 };
1426 assert_eq!(
1427 recover.validate(),
1428 Err(AgentProtocolError::InvalidField("checkpoint_run_id"))
1429 );
1430 }
1431
1432 #[test]
1433 fn run_state_from_covers_every_run_status_variant() {
1434 use crate::run::RunStatus;
1435 assert_eq!(
1436 AgentProtocolRunStateV1::from(RunStatus::Created),
1437 AgentProtocolRunStateV1::Created
1438 );
1439 assert_eq!(
1440 AgentProtocolRunStateV1::from(RunStatus::Planning),
1441 AgentProtocolRunStateV1::Planning
1442 );
1443 assert_eq!(
1444 AgentProtocolRunStateV1::from(RunStatus::Executing),
1445 AgentProtocolRunStateV1::Executing
1446 );
1447 assert_eq!(
1448 AgentProtocolRunStateV1::from(RunStatus::Verifying),
1449 AgentProtocolRunStateV1::Verifying
1450 );
1451 assert_eq!(
1452 AgentProtocolRunStateV1::from(RunStatus::Completed),
1453 AgentProtocolRunStateV1::Completed
1454 );
1455 assert_eq!(
1456 AgentProtocolRunStateV1::from(RunStatus::Failed),
1457 AgentProtocolRunStateV1::Failed
1458 );
1459 assert_eq!(
1460 AgentProtocolRunStateV1::from(RunStatus::Cancelled),
1461 AgentProtocolRunStateV1::Cancelled
1462 );
1463 assert!(AgentProtocolRunStateV1::Completed.is_terminal());
1464 assert!(!AgentProtocolRunStateV1::Executing.is_terminal());
1465 }
1466
1467 #[test]
1468 fn receipt_validation_rejects_zero_observed_at_and_nonterminal_cancel() {
1469 let command = AgentProtocolCommandV1::Cancel {
1470 request: AgentProtocolRunCancelV1 {
1471 schema: AgentProtocolRunCancelV1::SCHEMA.into(),
1472 request_id: "req-cancel".into(),
1473 identity: identity(),
1474 reason: "user".into(),
1475 },
1476 };
1477 let mut receipt = AgentProtocolCommandReceiptV1 {
1478 schema: AgentProtocolCommandReceiptV1::SCHEMA.into(),
1479 action: AgentProtocolCommandActionV1::Cancel,
1480 request_id: "req-cancel".into(),
1481 identity: identity(),
1482 command_digest: command.digest().expect("digest"),
1483 state: AgentProtocolRunStateV1::Cancelled,
1484 latest_event_sequence_exclusive: 1,
1485 observed_at_ms: 0,
1486 replayed: false,
1487 };
1488 assert_eq!(
1489 receipt.validate(),
1490 Err(AgentProtocolError::InvalidField("observed_at_ms"))
1491 );
1492 receipt.observed_at_ms = 10;
1493 receipt.state = AgentProtocolRunStateV1::Executing;
1494 assert_eq!(
1495 receipt.validate_for(&command),
1496 Err(AgentProtocolError::InvalidField("state"))
1497 );
1498 }
1499
1500 #[test]
1501 fn event_record_validate_for_rejects_bad_version_type_and_metadata() {
1502 let identity = identity();
1503 let mut record = AgentProtocolEventRecordV1 {
1504 sequence: 0,
1505 occurred_at_ms: 1,
1506 event: EventEnvelopeV1::new("text_delta", json!({"text": "ok"})).with_metadata(json!({
1507 "session_id": identity.session_id,
1508 "run_id": identity.run_id,
1509 "sequence": 0u64,
1510 "timestamp_ms": 1u64,
1511 })),
1512 };
1513 record.event.version = 99;
1514 assert_eq!(
1515 record.validate_for(&identity),
1516 Err(AgentProtocolError::InvalidField("event.version"))
1517 );
1518 record.event.version = EVENT_ENVELOPE_V1_VERSION;
1519 record.event.event_type = "bad\ntype".into();
1520 assert_eq!(
1521 record.validate_for(&identity),
1522 Err(AgentProtocolError::InvalidField("event.type"))
1523 );
1524 record.event.event_type = "text_delta".into();
1525 record.event.metadata = None;
1526 assert_eq!(
1527 record.validate_for(&identity),
1528 Err(AgentProtocolError::InvalidField("event.metadata"))
1529 );
1530 record.event.metadata = Some(json!("not-an-object"));
1531 assert_eq!(
1532 record.validate_for(&identity),
1533 Err(AgentProtocolError::InvalidField("event.metadata"))
1534 );
1535 record.event.metadata = Some(json!({
1536 "session_id": "other",
1537 "run_id": identity.run_id,
1538 "sequence": 0u64,
1539 "timestamp_ms": 1u64,
1540 }));
1541 assert_eq!(
1542 record.validate_for(&identity),
1543 Err(AgentProtocolError::IdentityMismatch)
1544 );
1545 }
1546
1547 #[test]
1548 fn event_page_validate_rejects_too_many_events_and_has_more_inconsistency() {
1549 let identity = identity();
1550 let make_event = |sequence: u64| AgentProtocolEventRecordV1 {
1551 sequence,
1552 occurred_at_ms: sequence + 1,
1553 event: EventEnvelopeV1::new("text_delta", json!({"text": "ok"})).with_metadata(json!({
1554 "session_id": identity.session_id,
1555 "run_id": identity.run_id,
1556 "sequence": sequence,
1557 "timestamp_ms": sequence + 1,
1558 })),
1559 };
1560 let mut page = AgentProtocolEventPageV1 {
1561 schema: AgentProtocolEventPageV1::SCHEMA.into(),
1562 identity: identity.clone(),
1563 after_event_sequence: None,
1564 first_available_sequence: Some(0),
1565 latest_sequence_exclusive: 1,
1566 next_after_event_sequence: Some(0),
1567 state: AgentProtocolRunStateV1::Completed,
1568 observed_at_ms: 100,
1569 retention_gap: false,
1570 has_more: false,
1571 events: vec![make_event(0)],
1572 };
1573 page.validate().expect("baseline page validates");
1574
1575 page.events = (0..=AGENT_PROTOCOL_MAX_EVENTS_PER_PAGE as u64)
1576 .map(make_event)
1577 .collect();
1578 page.latest_sequence_exclusive = page.events.len() as u64;
1579 page.next_after_event_sequence = Some(page.events.len() as u64 - 1);
1580 assert_eq!(
1581 page.validate(),
1582 Err(AgentProtocolError::InvalidField("events"))
1583 );
1584
1585 page.events = vec![make_event(0)];
1587 page.latest_sequence_exclusive = 1;
1588 page.next_after_event_sequence = Some(0);
1589 page.has_more = true;
1590 assert_eq!(
1591 page.validate(),
1592 Err(AgentProtocolError::InvalidField("has_more"))
1593 );
1594
1595 page.has_more = false;
1596 page.latest_sequence_exclusive = 1;
1597 page.next_after_event_sequence = Some(9);
1598 assert_eq!(
1599 page.validate(),
1600 Err(AgentProtocolError::InvalidField(
1601 "next_after_event_sequence"
1602 ))
1603 );
1604 }
1605
1606 #[test]
1607 fn change_set_validate_rejects_format_encoding_and_tree_errors() {
1608 let patch = b"diff --git a/x b/x\n";
1609 let mut change_set = AgentProtocolChangeSetV1 {
1610 schema: AgentProtocolChangeSetV1::SCHEMA.into(),
1611 identity: identity(),
1612 state: AgentProtocolRunStateV1::Completed,
1613 format: AGENT_PROTOCOL_CHANGE_SET_FORMAT_V1.into(),
1614 encoding: AGENT_PROTOCOL_CHANGE_SET_ENCODING_V1.into(),
1615 base_tree: format!("git-tree:{}", "1".repeat(40)),
1616 result_tree: format!("git-tree:{}", "2".repeat(40)),
1617 patch_digest: format!("sha256:{:x}", Sha256::digest(patch)),
1618 patch_bytes: patch.len() as u64,
1619 patch_base64: base64::engine::general_purpose::STANDARD.encode(patch),
1620 observed_at_ms: 10,
1621 };
1622 change_set.validate().expect("valid change set");
1623
1624 change_set.format = "other".into();
1625 assert_eq!(
1626 change_set.validate(),
1627 Err(AgentProtocolError::InvalidField("format"))
1628 );
1629 change_set.format = AGENT_PROTOCOL_CHANGE_SET_FORMAT_V1.into();
1630 change_set.encoding = "hex".into();
1631 assert_eq!(
1632 change_set.validate(),
1633 Err(AgentProtocolError::InvalidField("encoding"))
1634 );
1635 change_set.encoding = AGENT_PROTOCOL_CHANGE_SET_ENCODING_V1.into();
1636 change_set.base_tree = "not-a-tree".into();
1637 assert_eq!(
1638 change_set.validate(),
1639 Err(AgentProtocolError::InvalidField("base_tree"))
1640 );
1641 change_set.base_tree = format!("git-tree:{}", "1".repeat(40));
1642 change_set.observed_at_ms = 0;
1643 assert_eq!(
1644 change_set.validate(),
1645 Err(AgentProtocolError::InvalidField("patch_base64"))
1646 );
1647 }
1648
1649 #[test]
1650 fn validators_reject_bad_schema_ids_sha256_and_git_trees() {
1651 assert_eq!(
1652 validate_schema("wrong", AgentProtocolRunIdentityV1::SCHEMA),
1653 Err(AgentProtocolError::UnsupportedSchema)
1654 );
1655 assert_eq!(
1656 validate_id("session_id", ""),
1657 Err(AgentProtocolError::InvalidField("session_id"))
1658 );
1659 assert_eq!(
1660 validate_id("session_id", "has\nnewline"),
1661 Err(AgentProtocolError::InvalidField("session_id"))
1662 );
1663 assert_eq!(
1664 validate_lower_sha256("digest", "sha256:zzzz"),
1665 Err(AgentProtocolError::InvalidField("digest"))
1666 );
1667 assert_eq!(
1668 validate_git_tree("tree", "git-tree:xyz"),
1669 Err(AgentProtocolError::InvalidField("tree"))
1670 );
1671 assert!(validate_git_tree("tree", &format!("git-tree:{}", "a".repeat(64))).is_ok());
1672 }
1673
1674 #[test]
1675 fn change_set_request_digest_binds_validated_identity() {
1676 let request = AgentProtocolChangeSetRequestV1 {
1677 schema: AgentProtocolChangeSetRequestV1::SCHEMA.into(),
1678 identity: identity(),
1679 };
1680 request.validate().expect("valid");
1681 assert!(request.digest().expect("digest").starts_with("sha256:"));
1682 let mut bad = request.clone();
1683 bad.schema = "wrong".into();
1684 assert_eq!(bad.validate(), Err(AgentProtocolError::UnsupportedSchema));
1685 }
1686
1687 #[test]
1688 fn event_record_rejects_oversized_metadata_and_encoded_record() {
1689 let identity = identity();
1690 let mut record = AgentProtocolEventRecordV1 {
1691 sequence: 0,
1692 occurred_at_ms: 1,
1693 event: EventEnvelopeV1::new("text_delta", json!({"text": "ok"})).with_metadata(json!({
1694 "session_id": identity.session_id,
1695 "run_id": identity.run_id,
1696 "sequence": 0u64,
1697 "timestamp_ms": 1u64,
1698 "noise": "n".repeat(AGENT_PROTOCOL_MAX_EVENT_METADATA_BYTES + 64),
1699 })),
1700 };
1701 assert_eq!(
1702 record.validate_for(&identity),
1703 Err(AgentProtocolError::InvalidField("event.metadata"))
1704 );
1705
1706 record.event.metadata = Some(json!({
1709 "session_id": identity.session_id,
1710 "run_id": identity.run_id,
1711 "sequence": 0u64,
1712 "timestamp_ms": 1u64,
1713 }));
1714 record.event.event_type = "t".repeat(AGENT_PROTOCOL_MAX_EVENT_TYPE_BYTES + 1);
1715 assert_eq!(
1717 record.validate_for(&identity),
1718 Err(AgentProtocolError::InvalidField("event.type"))
1719 );
1720 }
1721
1722 #[test]
1723 fn bounded_event_payload_stub_skips_oversized_identity_fields_and_non_objects() {
1724 let digest = format!("sha256:{}", "cd".repeat(32));
1725 let original = json!({
1726 "id": "x".repeat(600),
1727 "name": "read",
1728 });
1729 let stub = bounded_event_payload_stub(&original, &digest).expect("stub");
1730 assert!(
1731 stub.get("id").is_none(),
1732 "fields over 512 bytes are skipped"
1733 );
1734 assert_eq!(stub["name"], "read");
1735
1736 let non_object = bounded_event_payload_stub(&json!("plain"), &digest).expect("non-object");
1737 assert_eq!(non_object["bounded"], true);
1738 assert!(non_object.get("id").is_none());
1739 }
1740
1741 #[test]
1742 fn shrink_helpers_return_false_when_no_eligible_string_exists() {
1743 let mut value = json!({
1744 "session_id": "keep-me-alone",
1745 "items": [1, true, null],
1746 "nested": {"run_id": "also-excluded"}
1747 });
1748 assert!(!shrink_largest_json_string_excluding(
1749 &mut value,
1750 AGENT_PROTOCOL_PAYLOAD_TRUNCATION_MARK,
1751 &["session_id", "run_id"],
1752 ));
1753 assert!(!shrink_first_string_of_len(
1754 &mut value,
1755 999,
1756 AGENT_PROTOCOL_PAYLOAD_TRUNCATION_MARK,
1757 &["session_id", "run_id"],
1758 ));
1759 }
1760
1761 #[test]
1762 fn command_action_and_request_id_cover_recover_variant() {
1763 let command = AgentProtocolCommandV1::Recover {
1764 request: AgentProtocolRunRecoverV1 {
1765 schema: AgentProtocolRunRecoverV1::SCHEMA.into(),
1766 request_id: "req-recover-variant".into(),
1767 identity: identity(),
1768 checkpoint_run_id: "checkpoint-source".into(),
1769 },
1770 };
1771 assert_eq!(command.action(), AgentProtocolCommandActionV1::Recover);
1772 assert_eq!(command.request_id(), "req-recover-variant");
1773 command.validate().expect("recover command validates");
1774 assert!(command.digest().expect("digest").starts_with("sha256:"));
1775 }
1776
1777 #[test]
1778 fn run_state_as_str_covers_every_variant() {
1779 for (state, name) in [
1780 (AgentProtocolRunStateV1::Created, "created"),
1781 (AgentProtocolRunStateV1::Planning, "planning"),
1782 (AgentProtocolRunStateV1::Executing, "executing"),
1783 (AgentProtocolRunStateV1::Verifying, "verifying"),
1784 (AgentProtocolRunStateV1::Completed, "completed"),
1785 (AgentProtocolRunStateV1::Failed, "failed"),
1786 (AgentProtocolRunStateV1::Cancelled, "cancelled"),
1787 ] {
1788 assert_eq!(state.as_str(), name);
1789 }
1790 }
1791
1792 #[test]
1793 fn event_page_request_rejects_zero_limit_and_digests_when_valid() {
1794 let mut request = AgentProtocolEventPageRequestV1 {
1795 schema: AgentProtocolEventPageRequestV1::SCHEMA.into(),
1796 identity: identity(),
1797 after_event_sequence: None,
1798 limit: 16,
1799 };
1800 request.validate().expect("valid");
1801 assert!(request.digest().expect("digest").starts_with("sha256:"));
1802 request.limit = 0;
1803 assert_eq!(
1804 request.validate(),
1805 Err(AgentProtocolError::InvalidField("limit"))
1806 );
1807 request.limit = (AGENT_PROTOCOL_MAX_EVENTS_PER_PAGE as u16).saturating_add(1);
1808 assert_eq!(
1809 request.validate(),
1810 Err(AgentProtocolError::InvalidField("limit"))
1811 );
1812 }
1813}