Skip to main content

a3s_code_core/
agent_protocol.rs

1//! Versioned headless protocol owned by A3S Code.
2//!
3//! Cloud and other hosts may transport these values, but A3S Code remains the
4//! authority for Agent session/run lifecycle, event names, cancellation, and
5//! checkpoint recovery. The protocol intentionally contains no Cloud tenant,
6//! scheduler, Workload, Runtime, or provider identity.
7
8use 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
32/// Canonical HTTP endpoint served by `a3s code harness` for v1 commands.
33pub const AGENT_PROTOCOL_COMMAND_HTTP_PATH_V1: &str = "/v1/agent/commands";
34
35/// Canonical HTTP endpoint served by `a3s code harness` for v1 event pages.
36pub const AGENT_PROTOCOL_EVENT_PAGE_HTTP_PATH_V1: &str = "/v1/agent/events:page";
37
38/// Canonical HTTP endpoint served by `a3s code harness` for immutable run changes.
39pub const AGENT_PROTOCOL_CHANGE_SET_HTTP_PATH_V1: &str = "/v1/agent/changes";
40
41/// Stable validation failures for the headless Agent protocol.
42#[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    /// Stable machine-readable error code for SDK and service boundaries.
56    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/// Exact A3S Code release, session, and run selected by a host.
67#[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/// Start a fresh A3S Code run with an exact host-selected identity.
96#[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/// Cancel the current exact A3S Code run.
123#[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/// Resume an A3S Code loop checkpoint into a fresh exact run identity.
144#[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/// Resume one exact, content-addressed A3S Code tool-round boundary.
169///
170/// This is an additive recovery request beside [`AgentProtocolRunRecoverV1`].
171/// The original request keeps its "latest checkpoint for this run" semantics
172/// and wire shape, while this request binds admission and its receipt to the
173/// complete secret-free descriptor of one portable Session checkpoint. The
174/// descriptor covers the semantic snapshot, logical-resume boundary, and
175/// aggregate canonical payload identity.
176#[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/// Closed actions accepted by the version-one Code Agent protocol.
218#[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/// One typed command for the A3S Code session/run lifecycle.
227#[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/// Stable wire projection of [`RunStatus`].
274#[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/// Exact observation returned after A3S Code accepts a command.
319#[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    /// Verify that this receipt settles one exact checkpoint recovery request.
364    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/// One authoritative A3S Code event at its run-local sequence.
382#[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        // Oversized tool_end / text payloads must still project: failing the
409        // whole page bricks host observation of terminal run state.
410        bound_projected_event_record(&mut projected)?;
411        projected.validate_for(identity)?;
412        Ok(projected)
413    }
414
415    /// Validate this exact record against its Code-owned run identity.
416    ///
417    /// Hosts use this at durable ingestion boundaries instead of copying the
418    /// event metadata and sequence rules into their own protocol layer.
419    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/// Bounded cursor query accepted by the A3S Code Harness event endpoint.
470#[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/// Cursor page projected directly from A3S Code's authoritative run store.
497#[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/// Exact run query accepted by the immutable change-set endpoint.
648#[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/// Immutable Git-compatible unified diff captured for one terminal Code run.
669///
670/// The base and result tree identities bind the diff to the exact workspace
671/// generations observed immediately before and after the run. The content
672/// digest and byte count let transports and local apply clients fail closed on
673/// truncation or mutation without inventing another run or checkpoint model.
674#[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
806/// Shrink oversized payload / non-identity metadata strings until the record
807/// validates. Prefer truncating the largest string leaves; if that cannot fit,
808/// replace the payload with a bounded stub that preserves small identity fields
809/// and a digest of the original payload.
810fn 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                // Metadata should stay small; strip non-identity keys.
848                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            // Halve the largest leaf each pass so oversized tool_end output/args
974            // converge under the protocol payload bound without many tiny cuts.
975            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        // after_event_sequence >= latest is invalid.
1273        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        // Non-contiguous event sequence after a valid cursor.
1317        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        // Empty events with retention_gap + first_available is invalid once the
1328        // retention_gap flag itself is consistent with the cursor math.
1329        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        // has_more=true requires room after the last event.
1586        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        // Keep metadata small but inflate the event type so the encoded record
1707        // exceeds AGENT_PROTOCOL_MAX_EVENT_RECORD_BYTES after identity checks.
1708        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        // Oversized event_type is rejected by validate_single_line first.
1716        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}