Skip to main content

arcp_core/messages/
execution.rs

1//! Execution messages — tools, jobs, agents, workflows (RFC §10).
2
3use serde::{Deserialize, Serialize};
4
5use crate::error::ErrorCode;
6use crate::ids::JobId;
7
8/// Parsed agent identifier per ARCP v1.1 §7.5.
9///
10/// Grammar (§7.5):
11///
12/// ```text
13/// agent   ::= name | name "@" version
14/// name    ::= [a-z0-9][a-z0-9._-]*
15/// version ::= [a-zA-Z0-9.+_-]+
16/// ```
17///
18/// `version` is `None` for a bare-name reference. Bare names resolve to
19/// the runtime's advertised `default` for that agent (see
20/// `Capabilities::agents`); pinned versions match exactly and surface
21/// [`crate::error::ErrorCode::AgentVersionNotAvailable`] if missing.
22#[derive(Debug, Clone, PartialEq, Eq, Hash)]
23pub struct AgentRef {
24    /// Bare agent name (without the `@version` suffix).
25    pub name: String,
26    /// Optional pinned version. `None` means "resolve to default".
27    pub version: Option<String>,
28}
29
30/// Error returned by [`AgentRef::parse`] for inputs that violate the
31/// §7.5 grammar.
32#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
33pub enum AgentRefParseError {
34    /// The bare-name component is empty or does not match
35    /// `[a-z0-9][a-z0-9._-]*`.
36    #[error("invalid agent name {0:?}")]
37    InvalidName(String),
38    /// The `version` component does not match `[a-zA-Z0-9.+_-]+`.
39    #[error("invalid agent version {0:?}")]
40    InvalidVersion(String),
41}
42
43const fn is_name_head(c: char) -> bool {
44    matches!(c, 'a'..='z' | '0'..='9')
45}
46
47const fn is_name_tail(c: char) -> bool {
48    matches!(c, 'a'..='z' | '0'..='9' | '.' | '_' | '-')
49}
50
51const fn is_version_char(c: char) -> bool {
52    matches!(c, 'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '+' | '_' | '-')
53}
54
55fn validate_name(name: &str) -> Result<(), AgentRefParseError> {
56    let mut chars = name.chars();
57    let Some(head) = chars.next() else {
58        return Err(AgentRefParseError::InvalidName(name.to_owned()));
59    };
60    if !is_name_head(head) {
61        return Err(AgentRefParseError::InvalidName(name.to_owned()));
62    }
63    for c in chars {
64        if !is_name_tail(c) {
65            return Err(AgentRefParseError::InvalidName(name.to_owned()));
66        }
67    }
68    Ok(())
69}
70
71fn validate_version(version: &str) -> Result<(), AgentRefParseError> {
72    if version.is_empty() {
73        return Err(AgentRefParseError::InvalidVersion(version.to_owned()));
74    }
75    for c in version.chars() {
76        if !is_version_char(c) {
77            return Err(AgentRefParseError::InvalidVersion(version.to_owned()));
78        }
79    }
80    Ok(())
81}
82
83impl AgentRef {
84    /// Parse an `agent` identifier per ARCP v1.1 §7.5.
85    ///
86    /// # Errors
87    ///
88    /// Returns [`AgentRefParseError`] when either the bare name or the
89    /// version component violates its grammar.
90    pub fn parse(input: &str) -> Result<Self, AgentRefParseError> {
91        if let Some(at) = input.find('@') {
92            let (name, rest) = input.split_at(at);
93            // `rest` includes the `@`; skip it.
94            let version = &rest[1..];
95            validate_name(name)?;
96            validate_version(version)?;
97            Ok(Self {
98                name: name.to_owned(),
99                version: Some(version.to_owned()),
100            })
101        } else {
102            validate_name(input)?;
103            Ok(Self {
104                name: input.to_owned(),
105                version: None,
106            })
107        }
108    }
109
110    /// Format back to the wire `name` or `name@version` string.
111    #[must_use]
112    pub fn format(&self) -> String {
113        self.version.as_ref().map_or_else(
114            || self.name.clone(),
115            |v| format!("{name}@{v}", name = self.name),
116        )
117    }
118}
119
120impl std::fmt::Display for AgentRef {
121    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122        f.write_str(&self.format())
123    }
124}
125
126impl std::str::FromStr for AgentRef {
127    type Err = AgentRefParseError;
128    fn from_str(s: &str) -> Result<Self, Self::Err> {
129        Self::parse(s)
130    }
131}
132
133impl Serialize for AgentRef {
134    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
135        serializer.serialize_str(&self.format())
136    }
137}
138
139impl<'de> Deserialize<'de> for AgentRef {
140    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
141        use serde::de::Error;
142        let raw = String::deserialize(deserializer)?;
143        Self::parse(&raw).map_err(D::Error::custom)
144    }
145}
146
147#[cfg(test)]
148#[allow(clippy::unwrap_used)]
149mod agent_ref_tests {
150    use super::*;
151
152    #[test]
153    fn parse_bare_name() {
154        let r = AgentRef::parse("code-refactor").unwrap();
155        assert_eq!(r.name, "code-refactor");
156        assert!(r.version.is_none());
157    }
158
159    #[test]
160    fn parse_name_at_version() {
161        let r = AgentRef::parse("code-refactor@2.0.0").unwrap();
162        assert_eq!(r.name, "code-refactor");
163        assert_eq!(r.version.as_deref(), Some("2.0.0"));
164    }
165
166    #[test]
167    fn format_round_trips() {
168        for s in ["a", "a-b", "a@1.0.0", "agent_x@v1.2.3+build.4"] {
169            let r = AgentRef::parse(s).unwrap();
170            assert_eq!(r.format(), s);
171        }
172    }
173
174    #[test]
175    fn rejects_uppercase_in_name() {
176        assert!(AgentRef::parse("CodeRefactor").is_err());
177        assert!(AgentRef::parse("Foo@1").is_err());
178    }
179
180    #[test]
181    fn rejects_empty_version() {
182        assert!(AgentRef::parse("ok@").is_err());
183    }
184
185    #[test]
186    fn serde_round_trip() {
187        let r = AgentRef::parse("web-research@1.0.0").unwrap();
188        let json = serde_json::to_string(&r).unwrap();
189        assert_eq!(json, "\"web-research@1.0.0\"");
190        let back: AgentRef = serde_json::from_str(&json).unwrap();
191        assert_eq!(back, r);
192    }
193}
194
195/// Payload for `tool.invoke`.
196#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
197pub struct ToolInvokePayload {
198    /// Tool identifier.
199    pub tool: String,
200    /// Tool-specific arguments.
201    pub arguments: serde_json::Value,
202    /// `cost.budget` lease capability for this job (ARCP v1.1 §9.6).
203    /// When present, the runtime tracks per-currency counters and
204    /// surfaces `BUDGET_EXHAUSTED` to the agent once any counter
205    /// reaches zero.
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub cost_budget: Option<crate::messages::permissions::CostBudget>,
208    /// Full ARCP v1.1 lease request. When both this and the legacy
209    /// `cost_budget` field are present, this block takes precedence.
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub lease_request: Option<crate::messages::permissions::LeaseRequest>,
212}
213
214impl ToolInvokePayload {
215    /// New `tool.invoke` payload with no budget.
216    #[must_use]
217    pub fn new(tool: impl Into<String>, arguments: serde_json::Value) -> Self {
218        Self {
219            tool: tool.into(),
220            arguments,
221            cost_budget: None,
222            lease_request: None,
223        }
224    }
225}
226
227/// Payload for `tool.result`.
228#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
229pub struct ToolResultPayload {
230    /// Tool result, inline.
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub value: Option<serde_json::Value>,
233    /// Tool result by reference (artifact).
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    pub result_ref: Option<crate::messages::artifacts::ArtifactRef>,
236}
237
238/// Payload for `tool.error`.
239#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
240pub struct ToolErrorPayload {
241    /// Canonical error code.
242    pub code: ErrorCode,
243    /// Whether the error is retryable.
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub retryable: Option<bool>,
246    /// Human-readable message.
247    pub message: String,
248    /// Optional structured detail.
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub details: Option<serde_json::Value>,
251}
252
253/// Job state (RFC §10.2).
254#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
255#[serde(rename_all = "lowercase")]
256pub enum JobState {
257    /// `accepted` — runtime accepted the command but has not started work.
258    Accepted,
259    /// `queued` — work is waiting for capacity.
260    Queued,
261    /// `running` — work is actively executing.
262    Running,
263    /// `blocked` — work is waiting on permission / human input.
264    Blocked,
265    /// `paused` — work was intentionally suspended.
266    Paused,
267    /// `completed` — work finished successfully.
268    Completed,
269    /// `failed` — work reached a terminal error.
270    Failed,
271    /// `cancelled` — work was cancelled.
272    Cancelled,
273}
274
275impl JobState {
276    /// True if this state is a terminal state.
277    #[must_use]
278    pub const fn is_terminal(self) -> bool {
279        matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
280    }
281
282    /// Wire-level string (e.g. `"running"`) per ARCP §10.2.
283    #[must_use]
284    pub const fn wire_str(self) -> &'static str {
285        match self {
286            Self::Accepted => "accepted",
287            Self::Queued => "queued",
288            Self::Running => "running",
289            Self::Blocked => "blocked",
290            Self::Paused => "paused",
291            Self::Completed => "completed",
292            Self::Failed => "failed",
293            Self::Cancelled => "cancelled",
294        }
295    }
296}
297
298/// Payload for `job.accepted`.
299#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
300pub struct JobAcceptedPayload {
301    /// Newly minted job id.
302    pub job_id: JobId,
303    /// Lease-bound credentials issued for this job.
304    #[serde(default, skip_serializing_if = "Vec::is_empty")]
305    pub credentials: Vec<crate::messages::credentials::ProvisionedCredential>,
306    /// Final lease constraints accepted by the runtime.
307    #[serde(default, skip_serializing_if = "Option::is_none")]
308    pub lease: Option<crate::messages::permissions::LeaseRequest>,
309}
310
311/// Payload for `job.started`.
312#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
313pub struct JobStartedPayload {
314    /// Optional human-readable description.
315    #[serde(default, skip_serializing_if = "Option::is_none")]
316    pub description: Option<String>,
317}
318
319/// Payload for `job.progress`.
320#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
321pub struct JobProgressPayload {
322    /// Percent complete, 0.0 to 100.0.
323    #[serde(default, skip_serializing_if = "Option::is_none")]
324    pub percent: Option<f64>,
325    /// Optional human-readable message.
326    #[serde(default, skip_serializing_if = "Option::is_none")]
327    pub message: Option<String>,
328}
329
330/// Payload for `job.heartbeat` (RFC §10.3).
331#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
332pub struct JobHeartbeatPayload {
333    /// Monotonically increasing per-job sequence number.
334    pub sequence: u64,
335    /// Optional per-heartbeat deadline override (ms).
336    #[serde(default, skip_serializing_if = "Option::is_none")]
337    pub deadline_ms: Option<u64>,
338    /// Current state at heartbeat time.
339    pub state: JobState,
340}
341
342/// Payload for `job.checkpoint` (v0.2).
343#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
344pub struct JobCheckpointPayload {
345    /// Checkpoint identifier.
346    pub checkpoint_id: String,
347    /// Opaque checkpoint data.
348    pub data: serde_json::Value,
349}
350
351/// Payload for `job.completed`.
352#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
353pub struct JobCompletedPayload {
354    /// Optional inline result.
355    #[serde(default, skip_serializing_if = "Option::is_none")]
356    pub value: Option<serde_json::Value>,
357    /// Optional artifact reference for the result.
358    #[serde(default, skip_serializing_if = "Option::is_none")]
359    pub result_ref: Option<crate::messages::artifacts::ArtifactRef>,
360    /// Stable identifier for a streamed result (ARCP v1.1 §8.4).
361    /// Present when the job emitted `job.result_chunk` events; references
362    /// the assembled chunks rather than carrying the value inline.
363    #[serde(default, skip_serializing_if = "Option::is_none")]
364    pub result_id: Option<String>,
365    /// Total decoded size in bytes of the streamed result (ARCP v1.1 §8.4).
366    /// Optional; informational for clients rendering progress.
367    #[serde(default, skip_serializing_if = "Option::is_none")]
368    pub result_size: Option<u64>,
369    /// Optional human-readable summary, typically supplied by the agent
370    /// alongside a streamed result (ARCP v1.1 §8.4 / §13.6).
371    #[serde(default, skip_serializing_if = "Option::is_none")]
372    pub summary: Option<String>,
373}
374
375/// Encoding of [`JobResultChunkPayload::data`] (ARCP v1.1 §8.4).
376#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
377#[serde(rename_all = "lowercase")]
378pub enum ResultChunkEncoding {
379    /// Chunk payload is a UTF-8 string fragment.
380    Utf8,
381    /// Chunk payload is a base64-encoded binary fragment.
382    Base64,
383}
384
385/// Payload for `job.result_chunk` (ARCP v1.1 §8.4 — `result_chunk`).
386///
387/// Streams the final result of a job in ordered fragments. The agent
388/// MUST emit chunks for one `result_id` in `chunk_seq` order; the
389/// terminating `job.completed` references the same `result_id`. Implementations
390/// MUST NOT mix inline result and `result_chunk` for the same job.
391#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
392pub struct JobResultChunkPayload {
393    /// Stable identifier for the assembled result. Generated by the
394    /// runtime (or agent) when streaming begins.
395    pub result_id: String,
396    /// 0-based monotonic chunk index per `result_id`.
397    pub chunk_seq: u64,
398    /// Chunk payload (text or base64-encoded bytes; see `encoding`).
399    pub data: String,
400    /// Wire-level encoding of `data`.
401    pub encoding: ResultChunkEncoding,
402    /// `true` when more chunks follow; `false` on the terminal chunk.
403    pub more: bool,
404}
405
406/// Helper that accumulates [`JobResultChunkPayload`] fragments for a
407/// single `result_id` and assembles the final payload when `more: false`
408/// arrives.
409///
410/// Chunks must be supplied in `chunk_seq` order — out-of-order chunks
411/// surface as [`ResultChunkError::OutOfOrder`]. Mixing encodings for the
412/// same `result_id` surfaces as [`ResultChunkError::EncodingMismatch`].
413#[derive(Debug, Default)]
414pub struct ResultChunkAssembler {
415    result_id: Option<String>,
416    encoding: Option<ResultChunkEncoding>,
417    next_seq: u64,
418    buffer: Vec<u8>,
419    finished: bool,
420}
421
422/// Errors returned by [`ResultChunkAssembler::push`] and
423/// [`ResultChunkAssembler::finish`].
424#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
425pub enum ResultChunkError {
426    /// A chunk arrived with a `chunk_seq` that did not match the next
427    /// expected sequence.
428    #[error("result_chunk out of order: expected seq {expected}, got {got}")]
429    OutOfOrder {
430        /// Expected next `chunk_seq`.
431        expected: u64,
432        /// Actual `chunk_seq` of the offending chunk.
433        got: u64,
434    },
435    /// A chunk's `result_id` differs from previously buffered chunks.
436    #[error("result_chunk result_id mismatch: expected {expected:?}, got {got:?}")]
437    ResultIdMismatch {
438        /// Expected `result_id` from the first buffered chunk.
439        expected: String,
440        /// Actual `result_id`.
441        got: String,
442    },
443    /// A chunk's `encoding` differs from previously buffered chunks.
444    #[error("result_chunk encoding mismatch: expected {expected:?}, got {got:?}")]
445    EncodingMismatch {
446        /// Encoding selected by the first chunk.
447        expected: ResultChunkEncoding,
448        /// Encoding on the offending chunk.
449        got: ResultChunkEncoding,
450    },
451    /// A base64 fragment failed to decode.
452    #[error("result_chunk base64 decode failed at seq {seq}")]
453    Base64Decode {
454        /// `chunk_seq` of the offending chunk.
455        seq: u64,
456    },
457    /// More chunks were pushed after a terminal `more: false`.
458    #[error("result_chunk: chunk pushed after final chunk")]
459    AfterFinal,
460    /// `finish` called before any terminal chunk arrived.
461    #[error("result_chunk: not yet final")]
462    NotFinal,
463}
464
465impl ResultChunkAssembler {
466    /// Construct an empty assembler.
467    #[must_use]
468    pub const fn new() -> Self {
469        Self {
470            result_id: None,
471            encoding: None,
472            next_seq: 0,
473            buffer: Vec::new(),
474            finished: false,
475        }
476    }
477
478    /// Append `chunk`. Returns `Ok(true)` if this chunk was terminal
479    /// (`more: false`), `Ok(false)` otherwise.
480    ///
481    /// # Errors
482    ///
483    /// Returns a [`ResultChunkError`] when the chunk violates ordering,
484    /// `result_id`, or encoding invariants, or when called after a
485    /// terminal chunk.
486    pub fn push(&mut self, chunk: &JobResultChunkPayload) -> Result<bool, ResultChunkError> {
487        if self.finished {
488            return Err(ResultChunkError::AfterFinal);
489        }
490        if chunk.chunk_seq != self.next_seq {
491            return Err(ResultChunkError::OutOfOrder {
492                expected: self.next_seq,
493                got: chunk.chunk_seq,
494            });
495        }
496        if let Some(rid) = self.result_id.as_deref() {
497            if rid != chunk.result_id {
498                return Err(ResultChunkError::ResultIdMismatch {
499                    expected: rid.to_owned(),
500                    got: chunk.result_id.clone(),
501                });
502            }
503        } else {
504            self.result_id = Some(chunk.result_id.clone());
505        }
506        if let Some(enc) = self.encoding {
507            if enc != chunk.encoding {
508                return Err(ResultChunkError::EncodingMismatch {
509                    expected: enc,
510                    got: chunk.encoding,
511                });
512            }
513        } else {
514            self.encoding = Some(chunk.encoding);
515        }
516        match chunk.encoding {
517            ResultChunkEncoding::Utf8 => {
518                self.buffer.extend_from_slice(chunk.data.as_bytes());
519            }
520            ResultChunkEncoding::Base64 => {
521                let decoded =
522                    decode_base64(&chunk.data).map_err(|()| ResultChunkError::Base64Decode {
523                        seq: chunk.chunk_seq,
524                    })?;
525                self.buffer.extend_from_slice(&decoded);
526            }
527        }
528        self.next_seq += 1;
529        if !chunk.more {
530            self.finished = true;
531        }
532        Ok(!chunk.more)
533    }
534
535    /// True once a terminal chunk has been pushed.
536    #[must_use]
537    pub const fn is_finished(&self) -> bool {
538        self.finished
539    }
540
541    /// The selected encoding, if any chunks have arrived.
542    #[must_use]
543    pub const fn encoding(&self) -> Option<ResultChunkEncoding> {
544        self.encoding
545    }
546
547    /// The `result_id` of the buffered stream, if any chunks have
548    /// arrived.
549    #[must_use]
550    pub fn result_id(&self) -> Option<&str> {
551        self.result_id.as_deref()
552    }
553
554    /// Consume the assembler and return the assembled bytes.
555    ///
556    /// # Errors
557    ///
558    /// Returns [`ResultChunkError::NotFinal`] if no terminal chunk has
559    /// arrived yet.
560    pub fn finish(self) -> Result<Vec<u8>, ResultChunkError> {
561        if !self.finished {
562            return Err(ResultChunkError::NotFinal);
563        }
564        Ok(self.buffer)
565    }
566
567    /// Consume the assembler and decode the buffer as UTF-8.
568    ///
569    /// # Errors
570    ///
571    /// Returns [`ResultChunkError::NotFinal`] if no terminal chunk has
572    /// arrived yet, or [`ResultChunkError::Base64Decode`] (with
573    /// `seq: u64::MAX`) if the assembled buffer is not valid UTF-8.
574    pub fn finish_utf8(self) -> Result<String, ResultChunkError> {
575        let bytes = self.finish()?;
576        String::from_utf8(bytes).map_err(|_| ResultChunkError::Base64Decode { seq: u64::MAX })
577    }
578}
579
580/// Minimal base64 decoder for [`ResultChunkAssembler`] so the crate need
581/// not pull in a base64 dependency.
582fn decode_base64(input: &str) -> Result<Vec<u8>, ()> {
583    const fn val(c: u8) -> Option<u8> {
584        match c {
585            b'A'..=b'Z' => Some(c - b'A'),
586            b'a'..=b'z' => Some(c - b'a' + 26),
587            b'0'..=b'9' => Some(c - b'0' + 52),
588            b'+' => Some(62),
589            b'/' => Some(63),
590            _ => None,
591        }
592    }
593    let bytes: Vec<u8> = input.bytes().filter(|b| !b.is_ascii_whitespace()).collect();
594    let (data, pad) = bytes
595        .iter()
596        .position(|&b| b == b'=')
597        .map_or((bytes.as_slice(), 0), |p| (&bytes[..p], bytes.len() - p));
598    if (data.len() + pad) % 4 != 0 {
599        return Err(());
600    }
601    let mut out = Vec::with_capacity(data.len() * 3 / 4);
602    let mut chunk = [0u8; 4];
603    let mut filled = 0;
604    for &b in data {
605        let v = val(b).ok_or(())?;
606        chunk[filled] = v;
607        filled += 1;
608        if filled == 4 {
609            out.push((chunk[0] << 2) | (chunk[1] >> 4));
610            out.push((chunk[1] << 4) | (chunk[2] >> 2));
611            out.push((chunk[2] << 6) | chunk[3]);
612            filled = 0;
613        }
614    }
615    match filled {
616        0 => {}
617        2 => out.push((chunk[0] << 2) | (chunk[1] >> 4)),
618        3 => {
619            out.push((chunk[0] << 2) | (chunk[1] >> 4));
620            out.push((chunk[1] << 4) | (chunk[2] >> 2));
621        }
622        _ => return Err(()),
623    }
624    Ok(out)
625}
626
627#[cfg(test)]
628#[allow(clippy::unwrap_used)]
629mod result_chunk_tests {
630    use super::*;
631
632    #[test]
633    fn utf8_chunks_assemble_in_order() {
634        let mut a = ResultChunkAssembler::new();
635        for (seq, fragment, more) in [(0u64, "hello ", true), (1, "world", false)] {
636            let done = a
637                .push(&JobResultChunkPayload {
638                    result_id: "res_x".into(),
639                    chunk_seq: seq,
640                    data: fragment.into(),
641                    encoding: ResultChunkEncoding::Utf8,
642                    more,
643                })
644                .unwrap();
645            assert_eq!(done, !more);
646        }
647        assert!(a.is_finished());
648        assert_eq!(a.finish_utf8().unwrap(), "hello world");
649    }
650
651    #[test]
652    fn out_of_order_chunks_rejected() {
653        let mut a = ResultChunkAssembler::new();
654        let _ = a
655            .push(&JobResultChunkPayload {
656                result_id: "r".into(),
657                chunk_seq: 0,
658                data: "a".into(),
659                encoding: ResultChunkEncoding::Utf8,
660                more: true,
661            })
662            .unwrap();
663        let err = a
664            .push(&JobResultChunkPayload {
665                result_id: "r".into(),
666                chunk_seq: 2,
667                data: "c".into(),
668                encoding: ResultChunkEncoding::Utf8,
669                more: false,
670            })
671            .unwrap_err();
672        assert!(matches!(
673            err,
674            ResultChunkError::OutOfOrder {
675                expected: 1,
676                got: 2
677            }
678        ));
679    }
680
681    #[test]
682    fn encoding_mismatch_rejected() {
683        let mut a = ResultChunkAssembler::new();
684        let _ = a
685            .push(&JobResultChunkPayload {
686                result_id: "r".into(),
687                chunk_seq: 0,
688                data: "a".into(),
689                encoding: ResultChunkEncoding::Utf8,
690                more: true,
691            })
692            .unwrap();
693        let err = a
694            .push(&JobResultChunkPayload {
695                result_id: "r".into(),
696                chunk_seq: 1,
697                data: "AA==".into(),
698                encoding: ResultChunkEncoding::Base64,
699                more: false,
700            })
701            .unwrap_err();
702        assert!(matches!(err, ResultChunkError::EncodingMismatch { .. }));
703    }
704
705    #[test]
706    fn base64_chunks_assemble() {
707        let mut a = ResultChunkAssembler::new();
708        // "hi" = 0x68, 0x69; base64 = "aGk="
709        a.push(&JobResultChunkPayload {
710            result_id: "r".into(),
711            chunk_seq: 0,
712            data: "aGk=".into(),
713            encoding: ResultChunkEncoding::Base64,
714            more: false,
715        })
716        .unwrap();
717        assert_eq!(a.finish().unwrap(), b"hi");
718    }
719
720    #[test]
721    fn finish_before_terminal_is_error() {
722        let mut a = ResultChunkAssembler::new();
723        a.push(&JobResultChunkPayload {
724            result_id: "r".into(),
725            chunk_seq: 0,
726            data: "x".into(),
727            encoding: ResultChunkEncoding::Utf8,
728            more: true,
729        })
730        .unwrap();
731        assert!(matches!(a.finish(), Err(ResultChunkError::NotFinal)));
732    }
733
734    #[test]
735    fn payload_round_trips_through_serde() {
736        let p = JobResultChunkPayload {
737            result_id: "res_01J".into(),
738            chunk_seq: 7,
739            data: "fragment".into(),
740            encoding: ResultChunkEncoding::Utf8,
741            more: true,
742        };
743        let j = serde_json::to_string(&p).unwrap();
744        let back: JobResultChunkPayload = serde_json::from_str(&j).unwrap();
745        assert_eq!(p, back);
746    }
747}
748
749/// Payload for `job.failed`.
750#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
751pub struct JobFailedPayload {
752    /// Canonical error code.
753    pub code: ErrorCode,
754    /// Whether the error is retryable.
755    #[serde(default, skip_serializing_if = "Option::is_none")]
756    pub retryable: Option<bool>,
757    /// Human-readable message.
758    pub message: String,
759    /// Optional structured detail.
760    #[serde(default, skip_serializing_if = "Option::is_none")]
761    pub details: Option<serde_json::Value>,
762}
763
764/// Payload for `job.cancelled`.
765#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
766pub struct JobCancelledPayload {
767    /// Free-form reason for cancellation.
768    #[serde(default, skip_serializing_if = "Option::is_none")]
769    pub reason: Option<String>,
770}
771
772/// Payload for `job.schedule` (v0.2).
773#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
774pub struct JobSchedulePayload {
775    /// Inner command envelope (e.g. `tool.invoke`).
776    pub job: serde_json::Value,
777    /// When to run (`at` / `every` / `after`).
778    pub when: serde_json::Value,
779}
780
781/// Payload for `agent.delegate` (v0.2 stub).
782#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
783pub struct AgentDelegatePayload {
784    /// Target agent identifier.
785    pub target: String,
786    /// Task description.
787    pub task: String,
788    /// Optional inherited context.
789    #[serde(default, skip_serializing_if = "Option::is_none")]
790    pub context: Option<serde_json::Value>,
791}
792
793/// Payload for `agent.handoff` (v0.2 stub).
794#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
795pub struct AgentHandoffPayload {
796    /// Target runtime identity.
797    pub runtime: serde_json::Value,
798    /// Optional human-readable reason.
799    #[serde(default, skip_serializing_if = "Option::is_none")]
800    pub reason: Option<String>,
801}
802
803/// Payload for `workflow.start` (v0.2 stub).
804#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
805pub struct WorkflowStartPayload {
806    /// Workflow identifier.
807    pub workflow: String,
808    /// Workflow-specific arguments.
809    #[serde(default, skip_serializing_if = "Option::is_none")]
810    pub arguments: Option<serde_json::Value>,
811}
812
813/// Payload for `workflow.complete` (v0.2 stub).
814#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
815pub struct WorkflowCompletePayload {
816    /// Optional final value.
817    #[serde(default, skip_serializing_if = "Option::is_none")]
818    pub value: Option<serde_json::Value>,
819}