Skip to main content

capsule_runtime/
a2a.rs

1use std::{
2    collections::{HashMap, HashSet},
3    sync::{
4        atomic::{AtomicU64, Ordering},
5        Arc,
6    },
7};
8
9use chrono::{DateTime, SecondsFormat, Utc};
10use murmur_artifact::TaskAcceptance;
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13use tokio::sync::oneshot;
14
15use crate::{
16    cancel::CancelSignal, identity::DOOR_EXTENSION_URI, lanes::TaskLane, origin::TaskProvenance,
17};
18
19// ── A2A protocol version ──────────────────────────────────────────────────────
20
21/// The HTTP header an A2A request names its protocol version in. Matched case-insensitively.
22pub const A2A_VERSION_HEADER: &str = "A2A-Version";
23
24/// The one A2A protocol version the door speaks, and the value every murmur client sends in
25/// [`A2A_VERSION_HEADER`].
26pub const A2A_PROTOCOL_VERSION: &str = "1.0";
27
28/// Whether a request whose [`A2A_VERSION_HEADER`] lines carried `values`, in order, speaks a
29/// version the door serves.
30///
31/// Exactly one line is accepted, holding [`A2A_PROTOCOL_VERSION`] or that version with a patch
32/// number (`1.0.1`), surrounding whitespace aside: a patch number takes no part in negotiation.
33/// No line, an empty value — which A2A reads as 0.3 — and more than one line are refused.
34pub(crate) fn accepts_a2a_version(values: &[String]) -> bool {
35    let [value] = values else {
36        return false;
37    };
38    let value = value.trim();
39    value == A2A_PROTOCOL_VERSION
40        || value
41            .strip_prefix(A2A_PROTOCOL_VERSION)
42            .and_then(|rest| rest.strip_prefix('.'))
43            .is_some_and(|patch| !patch.is_empty() && patch.bytes().all(|b| b.is_ascii_digit()))
44}
45
46/// The `VersionNotSupportedError` answering a request whose [`A2A_VERSION_HEADER`] lines carried
47/// `values`. `requestedVersion` is the values as sent, joined with `, `, and `""` when there were
48/// none.
49pub(crate) fn version_not_supported(id: Value, values: &[String]) -> JsonRpcResponse {
50    let requested = values
51        .iter()
52        .map(|value| value.trim())
53        .collect::<Vec<_>>()
54        .join(", ");
55    JsonRpcResponse::a2a_error(
56        id,
57        A2aError::VersionNotSupported,
58        &format!(
59            "A2A version '{requested}' is not supported; this agent speaks {A2A_PROTOCOL_VERSION}"
60        ),
61        &[
62            ("requestedVersion", requested.clone()),
63            ("supportedVersions", A2A_PROTOCOL_VERSION.to_string()),
64        ],
65    )
66}
67
68// ── A2A extensions ────────────────────────────────────────────────────────────
69
70/// The HTTP header a client activates A2A extensions in, and the header a response lists the
71/// extensions it activated in. Matched case-insensitively.
72pub const A2A_EXTENSIONS_HEADER: &str = "A2A-Extensions";
73
74/// Whether the [`A2A_EXTENSIONS_HEADER`] lines a request carried, `values`, activate `uri`.
75///
76/// Each line is a comma-separated list: every entry is trimmed, empty entries are dropped, and
77/// `uri` is activated when one entry equals it exactly. Any other URI is ignored, as an agent
78/// ignores a requested extension it does not support.
79pub(crate) fn activates_extension(values: &[String], uri: &str) -> bool {
80    values
81        .iter()
82        .flat_map(|value| value.split(','))
83        .map(str::trim)
84        .filter(|entry| !entry.is_empty())
85        .any(|entry| entry == uri)
86}
87
88/// The [`A2A_EXTENSIONS_HEADER`] line of a response that used `uris`, in that order, ending in
89/// `\r\n`; empty when it used none.
90pub(crate) fn extensions_echo(uris: &[&str]) -> String {
91    if uris.is_empty() {
92        return String::new();
93    }
94    format!("{A2A_EXTENSIONS_HEADER}: {}\r\n", uris.join(", "))
95}
96
97// ── JSON-RPC 2.0 envelope types ───────────────────────────────────────────────
98
99/// `-32700`: the body is not JSON.
100pub(crate) const PARSE_ERROR: i32 = -32700;
101/// `-32600`: the body is JSON but not a JSON-RPC 2.0 request.
102pub(crate) const INVALID_REQUEST: i32 = -32600;
103/// `-32601`: the door serves no method of this name.
104pub(crate) const METHOD_NOT_FOUND: i32 = -32601;
105/// `-32602`: the method's parameters are not what it takes.
106pub(crate) const INVALID_PARAMS: i32 = -32602;
107/// `-32603`: the door failed to answer a request it accepted.
108pub(crate) const INTERNAL_ERROR: i32 = -32603;
109
110/// A JSON-RPC 2.0 request, as [`JsonRpcRequest::from_body`] accepts it. Its `jsonrpc` was
111/// `"2.0"`, which is the only version there is to carry.
112#[derive(Debug, Clone)]
113pub(crate) struct JsonRpcRequest {
114    /// A string or an integer.
115    pub id: Value,
116    pub method: String,
117    /// The request's `params` as sent, or `{}` when it sent none. Not necessarily an object: a
118    /// method refuses any other value with `-32602` itself.
119    pub params: Value,
120}
121
122impl JsonRpcRequest {
123    /// The request `body` carries, or the error that answers it.
124    ///
125    /// A body that is not JSON is `-32700` with `id: null`. A body that is JSON but not a JSON-RPC
126    /// 2.0 request is `-32600`: one that is not an object (a batch among them), whose `jsonrpc` is
127    /// not exactly `"2.0"`, whose `method` is not a string, or whose `id` is absent or neither a
128    /// string nor an integer. That answer echoes the request's `id` when the `id` itself is valid,
129    /// and is `null` otherwise. An omitted `params` reads as `{}`; members beyond the four are
130    /// ignored.
131    pub(crate) fn from_body(body: &str) -> Result<JsonRpcRequest, JsonRpcResponse> {
132        let Ok(value) = serde_json::from_str::<Value>(body) else {
133            return Err(JsonRpcResponse::err(
134                Value::Null,
135                PARSE_ERROR,
136                "Invalid JSON payload",
137            ));
138        };
139        let Value::Object(mut object) = value else {
140            return Err(Self::invalid(Value::Null));
141        };
142        let id = object
143            .remove("id")
144            .filter(|id| id.is_string() || id.is_i64() || id.is_u64());
145        let jsonrpc = object.remove("jsonrpc");
146        let (Some(id), Some("2.0"), Some(Value::String(method))) = (
147            id.clone(),
148            jsonrpc.as_ref().and_then(Value::as_str),
149            object.remove("method"),
150        ) else {
151            return Err(Self::invalid(id.unwrap_or(Value::Null)));
152        };
153        let params = object
154            .remove("params")
155            .unwrap_or_else(|| Value::Object(serde_json::Map::new()));
156        Ok(JsonRpcRequest { id, method, params })
157    }
158
159    fn invalid(id: Value) -> JsonRpcResponse {
160        JsonRpcResponse::err(id, INVALID_REQUEST, "Request payload validation error")
161    }
162}
163
164#[derive(Debug, Clone, Serialize)]
165pub(crate) struct JsonRpcResponse {
166    pub jsonrpc: &'static str,
167    pub id: Value,
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub result: Option<Value>,
170    /// Boxed so a response is small enough to travel in a `Result`'s error variant, which is how
171    /// [`JsonRpcRequest::from_body`] answers a request it refuses.
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub error: Option<Box<JsonRpcError>>,
174}
175
176impl JsonRpcResponse {
177    pub(crate) fn ok(id: Value, result: impl Serialize) -> Self {
178        Self {
179            jsonrpc: "2.0",
180            id,
181            result: Some(serde_json::to_value(result).unwrap_or(Value::Null)),
182            error: None,
183        }
184    }
185
186    /// A JSON-RPC error with no `data`: the standard codes, `-32700` to `-32603`.
187    pub(crate) fn err(id: Value, code: i32, message: &str) -> Self {
188        Self::error(id, code, message, None)
189    }
190
191    /// The A2A error `error`, carrying a `google.rpc.ErrorInfo` in the `a2a-protocol.org` domain
192    /// whose `metadata` is `metadata`.
193    pub(crate) fn a2a_error(
194        id: Value,
195        error: A2aError,
196        message: &str,
197        metadata: &[(&str, String)],
198    ) -> Self {
199        let data = error_info(error.reason(), A2A_ERROR_DOMAIN, metadata);
200        Self::error(id, error.code(), message, Some(vec![data]))
201    }
202
203    /// The murmur error `error`, carrying a `google.rpc.ErrorInfo` in the [`MURMUR_ERROR_DOMAIN`]
204    /// whose `metadata` is `metadata`.
205    pub(crate) fn murmur_error(
206        id: Value,
207        error: MurmurError,
208        message: &str,
209        metadata: &[(&str, String)],
210    ) -> Self {
211        let data = error_info(error.reason(), MURMUR_ERROR_DOMAIN, metadata);
212        Self::error(id, error.code(), message, Some(vec![data]))
213    }
214
215    fn error(id: Value, code: i32, message: &str, data: Option<Vec<Value>>) -> Self {
216        Self {
217            jsonrpc: "2.0",
218            id,
219            result: None,
220            error: Some(Box::new(JsonRpcError {
221                code,
222                message: message.to_string(),
223                data,
224            })),
225        }
226    }
227
228    pub(crate) fn into_http_response(self) -> String {
229        self.into_http_response_echoing(&[])
230    }
231
232    /// [`Self::into_http_response`], listing `extensions` in [`A2A_EXTENSIONS_HEADER`] when the
233    /// response is a `result`. An error echoes nothing: a refused request used no extension.
234    pub(crate) fn into_http_response_echoing(self, extensions: &[&str]) -> String {
235        let echo = if self.error.is_none() {
236            extensions_echo(extensions)
237        } else {
238            String::new()
239        };
240        let body = serde_json::to_string(&self).unwrap_or_else(|_| "{}".to_string());
241        format!(
242            "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n{echo}\r\n{}",
243            body.len(),
244            body,
245        )
246    }
247}
248
249#[derive(Debug, Clone, Serialize)]
250pub(crate) struct JsonRpcError {
251    pub code: i32,
252    pub message: String,
253    /// One `google.rpc.ErrorInfo` for an A2A or murmur error, and absent for a standard one.
254    #[serde(skip_serializing_if = "Option::is_none")]
255    pub data: Option<Vec<Value>>,
256}
257
258/// The `@type` of the one detail an A2A or murmur error's `data` carries.
259const ERROR_INFO_TYPE: &str = "type.googleapis.com/google.rpc.ErrorInfo";
260
261/// The `ErrorInfo` `domain` of an A2A error.
262pub(crate) const A2A_ERROR_DOMAIN: &str = "a2a-protocol.org";
263
264/// The `ErrorInfo` `domain` of a [`MurmurError`].
265pub(crate) const MURMUR_ERROR_DOMAIN: &str = "murmur.nexus";
266
267/// A `google.rpc.ErrorInfo` detail. `metadata` holds string values only, as `ErrorInfo` requires.
268fn error_info(reason: &str, domain: &str, metadata: &[(&str, String)]) -> Value {
269    let metadata: serde_json::Map<String, Value> = metadata
270        .iter()
271        .map(|(key, value)| (key.to_string(), Value::String(value.clone())))
272        .collect();
273    serde_json::json!({
274        "@type": ERROR_INFO_TYPE,
275        "reason": reason,
276        "domain": domain,
277        "metadata": metadata,
278    })
279}
280
281/// The A2A errors, A2A v1.0 §5.4, in code order.
282///
283/// The door answers six of them: [`Self::TaskNotFound`], [`Self::TaskNotCancelable`],
284/// [`Self::PushNotificationNotSupported`], [`Self::UnsupportedOperation`],
285/// [`Self::ContentTypeNotSupported`] and [`Self::VersionNotSupported`]. The others are here so a
286/// client of a door names any error it is answered with through [`Self::from_code`].
287#[derive(Debug, Clone, Copy, PartialEq, Eq)]
288pub enum A2aError {
289    TaskNotFound,
290    TaskNotCancelable,
291    PushNotificationNotSupported,
292    UnsupportedOperation,
293    ContentTypeNotSupported,
294    InvalidAgentResponse,
295    ExtendedAgentCardNotConfigured,
296    ExtensionSupportRequired,
297    VersionNotSupported,
298}
299
300impl A2aError {
301    /// Every A2A error, in code order.
302    pub const ALL: [A2aError; 9] = [
303        A2aError::TaskNotFound,
304        A2aError::TaskNotCancelable,
305        A2aError::PushNotificationNotSupported,
306        A2aError::UnsupportedOperation,
307        A2aError::ContentTypeNotSupported,
308        A2aError::InvalidAgentResponse,
309        A2aError::ExtendedAgentCardNotConfigured,
310        A2aError::ExtensionSupportRequired,
311        A2aError::VersionNotSupported,
312    ];
313
314    /// The JSON-RPC `error.code`.
315    pub fn code(self) -> i32 {
316        match self {
317            A2aError::TaskNotFound => -32001,
318            A2aError::TaskNotCancelable => -32002,
319            A2aError::PushNotificationNotSupported => -32003,
320            A2aError::UnsupportedOperation => -32004,
321            A2aError::ContentTypeNotSupported => -32005,
322            A2aError::InvalidAgentResponse => -32006,
323            A2aError::ExtendedAgentCardNotConfigured => -32007,
324            A2aError::ExtensionSupportRequired => -32008,
325            A2aError::VersionNotSupported => -32009,
326        }
327    }
328
329    /// The `ErrorInfo` `reason`: the spec's error name in UPPER_SNAKE_CASE without `Error`.
330    pub fn reason(self) -> &'static str {
331        match self {
332            A2aError::TaskNotFound => "TASK_NOT_FOUND",
333            A2aError::TaskNotCancelable => "TASK_NOT_CANCELABLE",
334            A2aError::PushNotificationNotSupported => "PUSH_NOTIFICATION_NOT_SUPPORTED",
335            A2aError::UnsupportedOperation => "UNSUPPORTED_OPERATION",
336            A2aError::ContentTypeNotSupported => "CONTENT_TYPE_NOT_SUPPORTED",
337            A2aError::InvalidAgentResponse => "INVALID_AGENT_RESPONSE",
338            A2aError::ExtendedAgentCardNotConfigured => "EXTENDED_AGENT_CARD_NOT_CONFIGURED",
339            A2aError::ExtensionSupportRequired => "EXTENSION_SUPPORT_REQUIRED",
340            A2aError::VersionNotSupported => "VERSION_NOT_SUPPORTED",
341        }
342    }
343
344    /// The A2A error whose code is `code`, or `None` for a code A2A does not assign.
345    pub fn from_code(code: i32) -> Option<A2aError> {
346        Self::ALL.into_iter().find(|error| error.code() == code)
347    }
348}
349
350/// The errors a door answers a completion with: murmur's own protocol riding on `SendMessage`
351/// with `x-murmur-task-origin: completion`, so none of them is an A2A error.
352///
353/// Their codes lie outside the range JSON-RPC 2.0 reserves, which leaves them to applications,
354/// and the door extension documents them.
355#[derive(Debug, Clone, Copy, PartialEq, Eq)]
356pub enum MurmurError {
357    /// The completion's `x-murmur-completion-session` is not the session running here.
358    CompletionMisaddressed,
359    /// The completion names no delegation, names one no task here waits for, or names one this
360    /// session is ending.
361    CompletionNotAwaited,
362}
363
364impl MurmurError {
365    /// Every murmur error, in code order.
366    pub const ALL: [MurmurError; 2] = [
367        MurmurError::CompletionMisaddressed,
368        MurmurError::CompletionNotAwaited,
369    ];
370
371    /// The JSON-RPC `error.code`.
372    pub fn code(self) -> i32 {
373        match self {
374            MurmurError::CompletionMisaddressed => -31001,
375            MurmurError::CompletionNotAwaited => -31002,
376        }
377    }
378
379    /// The `ErrorInfo` `reason`.
380    pub fn reason(self) -> &'static str {
381        match self {
382            MurmurError::CompletionMisaddressed => "COMPLETION_MISADDRESSED",
383            MurmurError::CompletionNotAwaited => "COMPLETION_NOT_AWAITED",
384        }
385    }
386}
387
388// ── A2A protocol types ────────────────────────────────────────────────────────
389
390/// The `mediaType` of every text part murmur builds.
391pub(crate) const TEXT_MEDIA_TYPE: &str = "text/plain";
392
393/// The `mediaType` of every data part murmur builds.
394pub(crate) const DATA_MEDIA_TYPE: &str = "application/json";
395
396/// Who sent a message, A2A v1.0 `Role`, spelled as its ProtoJSON enum name.
397#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
398pub(crate) enum Role {
399    #[serde(rename = "ROLE_USER")]
400    User,
401    #[serde(rename = "ROLE_AGENT")]
402    Agent,
403}
404
405impl Role {
406    /// The role's ProtoJSON name, the one it serializes as.
407    pub(crate) fn wire_name(self) -> &'static str {
408        match self {
409            Self::User => "ROLE_USER",
410            Self::Agent => "ROLE_AGENT",
411        }
412    }
413}
414
415/// What one part carries: A2A v1.0 `Part`'s `content` oneof.
416#[derive(Debug, Clone, PartialEq)]
417pub(crate) enum PartContent {
418    Text(String),
419    /// Base64 file bytes, as ProtoJSON spells `bytes`. The door refuses a message carrying one.
420    Raw(String),
421    /// A file by reference. The door refuses a message carrying one.
422    Url(String),
423    /// Any JSON value, `null` included.
424    Data(Value),
425}
426
427impl PartContent {
428    /// The content's field name, which is also the kind a `task_start` record lists.
429    pub(crate) fn kind(&self) -> &'static str {
430        match self {
431            Self::Text(_) => "text",
432            Self::Raw(_) => "raw",
433            Self::Url(_) => "url",
434            Self::Data(_) => "data",
435        }
436    }
437}
438
439/// The four field names of [`PartContent`], in the proto's order.
440const PART_CONTENT_FIELDS: [&str; 4] = ["text", "raw", "url", "data"];
441
442/// One A2A v1.0 `Part`.
443///
444/// Serialized as one object holding exactly one content field beside the optional `mediaType`,
445/// `filename` and `metadata`. Read through [`Part::from_json`], which refuses an object that sets
446/// no content field or more than one, so no part deserializes to nothing.
447#[derive(Debug, Clone, PartialEq)]
448pub(crate) struct Part {
449    pub content: PartContent,
450    pub media_type: Option<String>,
451    pub filename: Option<String>,
452    pub metadata: Option<Value>,
453}
454
455impl Part {
456    /// A text part with `mediaType` [`TEXT_MEDIA_TYPE`].
457    pub(crate) fn text(text: impl Into<String>) -> Self {
458        Self::of(PartContent::Text(text.into()), TEXT_MEDIA_TYPE)
459    }
460
461    /// A data part with `mediaType` [`DATA_MEDIA_TYPE`].
462    pub(crate) fn data(value: Value) -> Self {
463        Self::of(PartContent::Data(value), DATA_MEDIA_TYPE)
464    }
465
466    fn of(content: PartContent, media_type: &str) -> Self {
467        Self {
468            content,
469            media_type: Some(media_type.to_string()),
470            filename: None,
471            metadata: None,
472        }
473    }
474
475    /// The part's text, when it is a text part.
476    pub(crate) fn as_text(&self) -> Option<&str> {
477        match &self.content {
478            PartContent::Text(text) => Some(text),
479            _ => None,
480        }
481    }
482
483    /// The part's value, when it is a data part.
484    #[cfg(test)]
485    pub(crate) fn as_data(&self) -> Option<&Value> {
486        match &self.content {
487            PartContent::Data(value) => Some(value),
488            _ => None,
489        }
490    }
491
492    /// The part as ProtoJSON.
493    pub(crate) fn to_json(&self) -> Value {
494        let mut object = serde_json::Map::new();
495        let (field, value) = match &self.content {
496            PartContent::Text(text) => ("text", Value::String(text.clone())),
497            PartContent::Raw(raw) => ("raw", Value::String(raw.clone())),
498            PartContent::Url(url) => ("url", Value::String(url.clone())),
499            PartContent::Data(value) => ("data", value.clone()),
500        };
501        object.insert(field.to_string(), value);
502        if let Some(media_type) = &self.media_type {
503            object.insert("mediaType".to_string(), Value::String(media_type.clone()));
504        }
505        if let Some(filename) = &self.filename {
506            object.insert("filename".to_string(), Value::String(filename.clone()));
507        }
508        if let Some(metadata) = &self.metadata {
509            object.insert("metadata".to_string(), metadata.clone());
510        }
511        Value::Object(object)
512    }
513
514    /// Why `value` sets no part content or more than one, or `None` when it sets exactly one.
515    /// A present `null` counts as set, as ProtoJSON reads it.
516    fn content_count_error(value: &Value) -> Option<String> {
517        let Some(object) = value.as_object() else {
518            return Some("is not an object".to_string());
519        };
520        let set: Vec<&str> = PART_CONTENT_FIELDS
521            .into_iter()
522            .filter(|field| object.contains_key(*field))
523            .collect();
524        match set.as_slice() {
525            [_] => None,
526            [] => Some("sets none of text, raw, url and data".to_string()),
527            more => Some(format!(
528                "sets {}; a part sets exactly one of text, raw, url and data",
529                more.join(" and ")
530            )),
531        }
532    }
533
534    /// The part `value` is, or why it is not one: an object setting exactly one of `text`, `raw`,
535    /// `url` and `data`, whose `text`, `raw`, `url`, `mediaType` and `filename` are strings where
536    /// present. Unknown fields are ignored.
537    pub(crate) fn from_json(value: &Value) -> Result<Part, String> {
538        if let Some(error) = Self::content_count_error(value) {
539            return Err(error);
540        }
541        let object = value.as_object().expect("checked to be an object");
542        let string = |field: &str| -> Result<Option<String>, String> {
543            match object.get(field) {
544                None => Ok(None),
545                Some(Value::String(text)) => Ok(Some(text.clone())),
546                Some(_) => Err(format!("{field} is not a string")),
547            }
548        };
549        let content = if let Some(text) = string("text")? {
550            PartContent::Text(text)
551        } else if let Some(raw) = string("raw")? {
552            PartContent::Raw(raw)
553        } else if let Some(url) = string("url")? {
554            PartContent::Url(url)
555        } else {
556            PartContent::Data(object.get("data").cloned().unwrap_or(Value::Null))
557        };
558        Ok(Part {
559            content,
560            media_type: string("mediaType")?,
561            filename: string("filename")?,
562            metadata: object.get("metadata").cloned(),
563        })
564    }
565}
566
567impl Serialize for Part {
568    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
569        self.to_json().serialize(serializer)
570    }
571}
572
573impl<'de> Deserialize<'de> for Part {
574    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
575        let value = Value::deserialize(deserializer)?;
576        Part::from_json(&value).map_err(|error| serde::de::Error::custom(format!("part {error}")))
577    }
578}
579
580#[derive(Debug, Clone, Serialize, Deserialize)]
581#[serde(rename_all = "camelCase")]
582pub(crate) struct A2aTask {
583    pub id: String,
584    pub context_id: String,
585    pub status: TaskStatus,
586    #[serde(skip_serializing_if = "Option::is_none")]
587    pub artifacts: Option<Vec<A2aArtifact>>,
588    /// `{"murmur": {...}}` for a terminal task with something to say about answers it lacks:
589    /// `noAnswer: true` for one ended through `end-without-answer`, and `noAnswerBelow`, the
590    /// members further down that gave it none. Absent when it has neither.
591    #[serde(default, skip_serializing_if = "Option::is_none")]
592    pub metadata: Option<serde_json::Value>,
593}
594
595impl A2aTask {
596    /// The artifact whose `artifactId` is `artifact_id`.
597    pub(crate) fn artifact(&self, artifact_id: &str) -> Option<&A2aArtifact> {
598        self.artifacts
599            .as_deref()?
600            .iter()
601            .find(|artifact| artifact.artifact_id == artifact_id)
602    }
603
604    /// The text of the status message, when the status carries one with a text part.
605    pub(crate) fn status_text(&self) -> Option<&str> {
606        self.status.message.as_ref()?.text()
607    }
608}
609
610/// An A2A v1.0 `Artifact`. Murmur names each artifact it builds once per task, so the name is
611/// also its `artifactId`: unique within the task, and the same on every read.
612#[derive(Debug, Clone, Serialize, Deserialize)]
613#[serde(rename_all = "camelCase")]
614pub(crate) struct A2aArtifact {
615    pub artifact_id: String,
616    #[serde(default)]
617    pub name: String,
618    pub parts: Vec<Part>,
619}
620
621impl A2aArtifact {
622    /// The artifact `name`, holding `parts`.
623    pub(crate) fn named(name: &str, parts: Vec<Part>) -> Self {
624        Self {
625            artifact_id: name.to_string(),
626            name: name.to_string(),
627            parts,
628        }
629    }
630
631    /// The text of the first part, when it is a text part.
632    pub(crate) fn first_text(&self) -> Option<&str> {
633        self.parts.first()?.as_text()
634    }
635}
636
637#[derive(Debug, Clone, Serialize, Deserialize)]
638pub(crate) struct TaskStatus {
639    pub state: TaskState,
640    /// What the task's final status said, for a terminal task that said something.
641    #[serde(default, skip_serializing_if = "Option::is_none")]
642    pub message: Option<StatusMessage>,
643    /// When this status was recorded, as [`status_timestamp`] writes it. Every status murmur
644    /// writes carries one; a peer's answer may omit it.
645    #[serde(default, skip_serializing_if = "Option::is_none")]
646    pub timestamp: Option<String>,
647}
648
649impl TaskStatus {
650    /// A status in `state` with no message, recorded now.
651    pub(crate) fn of(state: TaskState) -> Self {
652        Self::at(state, None, status_now())
653    }
654
655    /// A status in `state` saying `message`, recorded at `when`.
656    pub(crate) fn at(
657        state: TaskState,
658        message: Option<StatusMessage>,
659        when: DateTime<Utc>,
660    ) -> Self {
661        Self {
662            state,
663            message,
664            timestamp: Some(status_timestamp(when)),
665        }
666    }
667}
668
669/// The current instant, truncated to the millisecond a status timestamp carries, so the string
670/// [`status_timestamp`] writes parses back to exactly the instant stored.
671pub(crate) fn status_now() -> DateTime<Utc> {
672    let now = Utc::now();
673    DateTime::from_timestamp_millis(now.timestamp_millis()).unwrap_or(now)
674}
675
676/// The instant of serialized `status`'s `timestamp`, asserting it is one [`status_timestamp`]
677/// could have written: RFC 3339, in UTC with a `Z`, to the millisecond.
678#[cfg(test)]
679pub(crate) fn assert_status_timestamp(status: &Value) -> DateTime<Utc> {
680    let timestamp = status["timestamp"]
681        .as_str()
682        .unwrap_or_else(|| panic!("no timestamp: {status}"));
683    let parsed = DateTime::parse_from_rfc3339(timestamp)
684        .unwrap_or_else(|error| panic!("{timestamp} is not RFC 3339: {error}"))
685        .with_timezone(&Utc);
686    assert_eq!(status_timestamp(parsed), timestamp, "{status}");
687    parsed
688}
689
690/// Asserts `status` is a serialized status in `state` with no message and a `timestamp`
691/// [`status_timestamp`] could have written.
692#[cfg(test)]
693pub(crate) fn assert_bare_status(status: &Value, state: &str) {
694    assert_status_timestamp(status);
695    assert_eq!(
696        *status,
697        serde_json::json!({"state": state, "timestamp": status["timestamp"]})
698    );
699}
700
701/// `at` as a status's `timestamp`: RFC 3339 in UTC with milliseconds and a `Z`, such as
702/// `2026-10-13T09:00:00.123Z`, which is what A2A v1.0's `google.protobuf.Timestamp` takes.
703pub(crate) fn status_timestamp(at: DateTime<Utc>) -> String {
704    at.to_rfc3339_opts(SecondsFormat::Millis, true)
705}
706
707/// A task status's message: an A2A message from the agent about one task, with one text part.
708#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
709#[serde(rename_all = "camelCase")]
710pub(crate) struct StatusMessage {
711    pub message_id: String,
712    pub context_id: String,
713    pub task_id: String,
714    pub role: Role,
715    pub parts: Vec<Part>,
716}
717
718impl StatusMessage {
719    /// The agent's message `text` about `task_id`, in `context_id`.
720    pub(crate) fn agent(task_id: &str, context_id: &str, text: &str) -> Self {
721        Self {
722            message_id: format!("msg_{task_id}_status"),
723            context_id: context_id.to_string(),
724            task_id: task_id.to_string(),
725            role: Role::Agent,
726            parts: vec![Part::text(text)],
727        }
728    }
729
730    /// The text of the first part, when it is a text part.
731    pub(crate) fn text(&self) -> Option<&str> {
732        self.parts.first()?.as_text()
733    }
734}
735
736/// How a finished task ended, as its final status reported it.
737#[derive(Debug, Clone, PartialEq, Eq)]
738struct TaskEnding {
739    /// The final status's message.
740    message: String,
741    /// The task's response text, kept only for a `completed` task.
742    response: Option<String>,
743    /// Whether the task ended through `end-without-answer`.
744    no_answer: bool,
745    /// `(member, status)` for each member further down that gave the task no answer.
746    no_answer_below: Vec<(String, String)>,
747}
748
749impl TaskEnding {
750    /// The `metadata` `GetTask` carries for this ending, or `None` when it says nothing.
751    fn metadata(&self) -> Option<serde_json::Value> {
752        let mut murmur = serde_json::Map::new();
753        if self.no_answer {
754            murmur.insert("noAnswer".to_string(), serde_json::Value::Bool(true));
755        }
756        if !self.no_answer_below.is_empty() {
757            let below = self
758                .no_answer_below
759                .iter()
760                .map(|(member, status)| serde_json::json!({"member": member, "status": status}))
761                .collect();
762            murmur.insert("noAnswerBelow".to_string(), serde_json::Value::Array(below));
763        }
764        (!murmur.is_empty()).then(|| serde_json::json!({ "murmur": murmur }))
765    }
766}
767
768/// The name and `artifactId` of the artifact a completed task's response is carried in over
769/// `GetTask`.
770pub(crate) const RESPONSE_ARTIFACT: &str = "response";
771
772/// The name and `artifactId` of the artifact an `input-required` task's prompt is carried in.
773pub(crate) const PROMPT_ARTIFACT: &str = "prompt";
774
775/// A task's state.
776///
777/// It has two spellings. On the A2A wire it is the v1.0 ProtoJSON enum name, which serde and
778/// [`Self::wire_name`] give and which is the only spelling it deserializes from; every stream
779/// event and murmur frame carries that `TASK_STATE_*` name too. Everywhere murmur chooses its own
780/// words — every sentence written for the model, the guest's `task-result.state`, `mur` output
781/// and trace records — it is murmur's word, [`Self::as_str`].
782///
783/// The door never produces `TASK_STATE_AUTH_REQUIRED`: it authenticates a request before any task
784/// exists, so no task can wait for authentication.
785#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
786pub(crate) enum TaskState {
787    #[serde(rename = "TASK_STATE_SUBMITTED")]
788    Submitted,
789    #[serde(rename = "TASK_STATE_WORKING")]
790    Working,
791    #[serde(rename = "TASK_STATE_INPUT_REQUIRED")]
792    InputRequired,
793    #[serde(rename = "TASK_STATE_COMPLETED")]
794    Completed,
795    #[serde(rename = "TASK_STATE_FAILED")]
796    Failed,
797    #[serde(rename = "TASK_STATE_REJECTED")]
798    Rejected,
799    /// A person stopped this task. Terminal, and distinct from `Failed`: nothing went wrong, the
800    /// work was called off.
801    #[serde(rename = "TASK_STATE_CANCELED")]
802    Canceled,
803}
804
805impl TaskState {
806    /// Every state, in the proto's order.
807    pub(crate) const ALL: [TaskState; 7] = [
808        TaskState::Submitted,
809        TaskState::Working,
810        TaskState::Completed,
811        TaskState::Failed,
812        TaskState::Canceled,
813        TaskState::InputRequired,
814        TaskState::Rejected,
815    ];
816
817    /// The state's A2A v1.0 ProtoJSON name, the one it serializes as.
818    pub(crate) fn wire_name(&self) -> &'static str {
819        match self {
820            Self::Submitted => "TASK_STATE_SUBMITTED",
821            Self::Working => "TASK_STATE_WORKING",
822            Self::InputRequired => "TASK_STATE_INPUT_REQUIRED",
823            Self::Completed => "TASK_STATE_COMPLETED",
824            Self::Failed => "TASK_STATE_FAILED",
825            Self::Rejected => "TASK_STATE_REJECTED",
826            Self::Canceled => "TASK_STATE_CANCELED",
827        }
828    }
829
830    /// The state whose A2A v1.0 ProtoJSON name is `wire`, or `None` for `TASK_STATE_UNSPECIFIED`,
831    /// `TASK_STATE_AUTH_REQUIRED`, which no task here is ever in, and anything else.
832    pub(crate) fn from_wire_name(wire: &str) -> Option<TaskState> {
833        Self::ALL
834            .into_iter()
835            .find(|state| state.wire_name() == wire)
836    }
837
838    /// The state in murmur's own word, the one sentences written for the model carry:
839    /// [`TaskRegistry::reference_block`] and `call-member`'s answers. The guest's
840    /// `task-result.state` and `mur` output reach the same word from a wire name through
841    /// [`murmur_state_name`], and a test holds the two equal.
842    pub(crate) fn as_str(&self) -> &'static str {
843        match self {
844            Self::Submitted => "submitted",
845            Self::Working => "working",
846            Self::InputRequired => "input-required",
847            Self::Completed => "completed",
848            Self::Failed => "failed",
849            Self::Rejected => "rejected",
850            Self::Canceled => "canceled",
851        }
852    }
853
854    /// Whether no further work will be done on a task in this state.
855    ///
856    /// The one rule a cancel reads: a terminal task is left exactly as it is, and only a live one
857    /// can be stopped.
858    pub(crate) fn is_terminal(&self) -> bool {
859        match self {
860            Self::Submitted | Self::Working | Self::InputRequired => false,
861            Self::Completed | Self::Failed | Self::Rejected | Self::Canceled => true,
862        }
863    }
864}
865
866/// Murmur's word for the A2A v1.0 task state `wire`, or `None` for `TASK_STATE_UNSPECIFIED` and
867/// anything that is not a v1.0 state name.
868///
869/// Total over the eight meaningful v1.0 states, `TASK_STATE_AUTH_REQUIRED` among them, which is
870/// `auth-required`, A2A 0.3's word for it: a peer that is not murmur may answer it, though this
871/// door never does. This is how a state read off the wire reaches the guest and `mur` output.
872pub fn murmur_state_name(wire: &str) -> Option<&'static str> {
873    Some(match wire {
874        "TASK_STATE_SUBMITTED" => "submitted",
875        "TASK_STATE_WORKING" => "working",
876        "TASK_STATE_COMPLETED" => "completed",
877        "TASK_STATE_FAILED" => "failed",
878        "TASK_STATE_CANCELED" => "canceled",
879        "TASK_STATE_INPUT_REQUIRED" => "input-required",
880        "TASK_STATE_REJECTED" => "rejected",
881        "TASK_STATE_AUTH_REQUIRED" => "auth-required",
882        _ => return None,
883    })
884}
885
886// ── Incoming messages ─────────────────────────────────────────────────────────
887
888/// The most distinct task ids one message may name in `referenceTaskIds`.
889pub(crate) const MAX_REFERENCE_TASK_IDS: usize = 16;
890
891/// A `SendMessage` or `SendStreamingMessage` message the door read in full, as
892/// [`read_send_params`] returns it.
893#[derive(Debug, Clone, PartialEq)]
894pub(crate) struct IncomingMessage {
895    pub message_id: String,
896    pub context_id: Option<String>,
897    /// The task this message continues. An empty `taskId` reads as `None`.
898    pub task_id: Option<String>,
899    /// `referenceTaskIds` with duplicates collapsed to their first occurrence.
900    pub reference_task_ids: Vec<String>,
901    /// Text and data parts only: a message carrying any other part is refused.
902    pub parts: Vec<Part>,
903    /// Whether `metadata` carries the door extension's key, [`DOOR_EXTENSION_URI`]: the message
904    /// used that extension, so a response that serves it lists the URI in
905    /// [`A2A_EXTENSIONS_HEADER`].
906    pub door_extension: bool,
907    /// `metadata[DOOR_EXTENSION_URI].forgetSession`: the task this message starts drops the
908    /// harness session its context names before its first attempt. Never `true` on a message
909    /// that names a `taskId`.
910    pub forget_session: bool,
911    /// `configuration.returnImmediately`: `SendMessage` answers once the task is accepted rather
912    /// than once it settles. `SendStreamingMessage` reads no meaning into it.
913    pub return_immediately: bool,
914}
915
916/// Why the door refused a message, and so answered no task.
917#[derive(Debug, Clone, PartialEq, Eq)]
918pub(crate) enum MessageRefusal {
919    /// `-32602`: the message is not an A2A v1.0 message. The text says what is wrong.
920    InvalidParams(String),
921    /// `-32005` `ContentTypeNotSupportedError`: the part at `part_index` is a file part, `raw` or
922    /// `url`, which this door does not read.
923    ContentTypeNotSupported {
924        part_index: usize,
925        kind: &'static str,
926        media_type: Option<String>,
927    },
928    /// `-32003` `PushNotificationNotSupportedError`: `configuration.taskPushNotificationConfig`
929    /// asks to be notified, and this agent sends no push notifications.
930    PushNotificationNotSupported,
931}
932
933impl MessageRefusal {
934    /// The JSON-RPC error answering request `id` with this refusal.
935    pub(crate) fn into_response(self, id: Value) -> JsonRpcResponse {
936        match self {
937            Self::InvalidParams(message) => {
938                JsonRpcResponse::err(id, INVALID_PARAMS, &format!("Invalid params: {message}"))
939            }
940            Self::ContentTypeNotSupported {
941                part_index,
942                kind,
943                media_type,
944            } => {
945                let mut metadata = vec![("partIndex", part_index.to_string())];
946                if let Some(media_type) = &media_type {
947                    metadata.push(("mediaType", media_type.clone()));
948                }
949                JsonRpcResponse::a2a_error(
950                    id,
951                    A2aError::ContentTypeNotSupported,
952                    &format!(
953                        "message.parts[{part_index}] is a {kind} part; this agent reads text and \
954                         data parts only"
955                    ),
956                    &metadata,
957                )
958            }
959            Self::PushNotificationNotSupported => push_notification_not_supported(id),
960        }
961    }
962}
963
964/// The `PushNotificationNotSupportedError` answering request `id`: this agent's card says
965/// `capabilities.pushNotifications: false`, so it neither stores a push config nor notifies.
966pub(crate) fn push_notification_not_supported(id: Value) -> JsonRpcResponse {
967    JsonRpcResponse::a2a_error(
968        id,
969        A2aError::PushNotificationNotSupported,
970        "push notifications are not supported by this agent",
971        &[],
972    )
973}
974
975/// The message a `SendMessage` or `SendStreamingMessage` request's `params` carries, or why it is
976/// refused.
977///
978/// Read by hand rather than by serde, in this order, each failure `-32602` but the last:
979/// `params.message` is an object; `messageId` is a non-empty string; `role` is `ROLE_USER`; `parts`
980/// is a non-empty array; every part sets exactly one of `text`, `raw`, `url` and `data`; `text`,
981/// `raw`, `url`, `mediaType` and `filename` are strings where present; `taskId` and `contextId`
982/// are strings where present; `referenceTaskIds` is an array of non-empty strings naming at most
983/// [`MAX_REFERENCE_TASK_IDS`] distinct tasks; `metadata[DOOR_EXTENSION_URI]` is an object where
984/// present; its `forgetSession` is a boolean where present; `forgetSession: true` names no
985/// `taskId`; `configuration.returnImmediately` is a boolean where present and not `null`. Then the
986/// first `raw` or `url` part is [`MessageRefusal::ContentTypeNotSupported`], and a
987/// `configuration.taskPushNotificationConfig` that is present and not `null` is
988/// [`MessageRefusal::PushNotificationNotSupported`]. The rest of `configuration` and unknown
989/// fields are ignored, a `configuration` that is not an object, a `metadata` that is not an object
990/// and every other key of it and of the door extension's object among them.
991pub(crate) fn read_send_params(params: &Value) -> Result<IncomingMessage, MessageRefusal> {
992    let invalid = |message: String| Err(MessageRefusal::InvalidParams(message));
993    let Some(message) = params.get("message").and_then(Value::as_object) else {
994        return invalid("params.message must be an A2A Message object".to_string());
995    };
996    let message_id = match message.get("messageId") {
997        Some(Value::String(id)) if !id.is_empty() => id.clone(),
998        _ => return invalid("message.messageId must be a non-empty string".to_string()),
999    };
1000    if message.get("role").and_then(Value::as_str) != Some(Role::User.wire_name()) {
1001        return invalid(format!(
1002            "message.role must be {}: a message to this agent is from the user",
1003            Role::User.wire_name()
1004        ));
1005    }
1006    let raw_parts = match message.get("parts") {
1007        Some(Value::Array(parts)) if !parts.is_empty() => parts,
1008        _ => return invalid("message.parts must be a non-empty array".to_string()),
1009    };
1010    for (index, part) in raw_parts.iter().enumerate() {
1011        if let Some(error) = Part::content_count_error(part) {
1012            return invalid(format!("message.parts[{index}] {error}"));
1013        }
1014    }
1015    let mut parts = Vec::with_capacity(raw_parts.len());
1016    for (index, part) in raw_parts.iter().enumerate() {
1017        match Part::from_json(part) {
1018            Ok(part) => parts.push(part),
1019            Err(error) => return invalid(format!("message.parts[{index}].{error}")),
1020        }
1021    }
1022    let optional_string = |field: &str| -> Result<Option<String>, MessageRefusal> {
1023        match message.get(field) {
1024            None => Ok(None),
1025            Some(Value::String(value)) => Ok(Some(value.clone())),
1026            Some(_) => Err(MessageRefusal::InvalidParams(format!(
1027                "message.{field} must be a string"
1028            ))),
1029        }
1030    };
1031    let task_id = optional_string("taskId")?.filter(|id| !id.is_empty());
1032    let context_id = optional_string("contextId")?;
1033    let reference_task_ids = match message.get("referenceTaskIds") {
1034        None => Vec::new(),
1035        Some(Value::Array(ids)) => {
1036            let mut distinct: Vec<String> = Vec::new();
1037            for id in ids {
1038                match id {
1039                    Value::String(id) if !id.is_empty() => {
1040                        if !distinct.contains(id) {
1041                            distinct.push(id.clone());
1042                        }
1043                    }
1044                    _ => {
1045                        return invalid(
1046                            "message.referenceTaskIds must hold non-empty strings".to_string(),
1047                        )
1048                    }
1049                }
1050            }
1051            if distinct.len() > MAX_REFERENCE_TASK_IDS {
1052                return invalid(format!(
1053                    "message.referenceTaskIds names {} tasks; at most {MAX_REFERENCE_TASK_IDS} \
1054                     may be referenced",
1055                    distinct.len()
1056                ));
1057            }
1058            distinct
1059        }
1060        Some(_) => return invalid("message.referenceTaskIds must be an array".to_string()),
1061    };
1062    let door_metadata = message
1063        .get("metadata")
1064        .and_then(Value::as_object)
1065        .and_then(|metadata| metadata.get(DOOR_EXTENSION_URI));
1066    let forget_session = match door_metadata {
1067        None => false,
1068        Some(Value::Object(door)) => match door.get("forgetSession") {
1069            None => false,
1070            Some(Value::Bool(forget)) => *forget,
1071            Some(_) => {
1072                return invalid(format!(
1073                    "message.metadata[\"{DOOR_EXTENSION_URI}\"].forgetSession must be a boolean"
1074                ))
1075            }
1076        },
1077        Some(_) => {
1078            return invalid(format!(
1079                "message.metadata[\"{DOOR_EXTENSION_URI}\"] must be an object"
1080            ))
1081        }
1082    };
1083    if let (true, Some(task_id)) = (forget_session, &task_id) {
1084        return invalid(format!(
1085            "forgetSession starts a new conversation, so it goes on a message that starts a task, \
1086             not on one that continues task {task_id}"
1087        ));
1088    }
1089    let configuration = params.get("configuration");
1090    let return_immediately = match configuration.and_then(|c| c.get("returnImmediately")) {
1091        None | Some(Value::Null) => false,
1092        Some(Value::Bool(immediately)) => *immediately,
1093        Some(_) => return invalid("configuration.returnImmediately must be a boolean".to_string()),
1094    };
1095    if let Some((part_index, part)) = parts
1096        .iter()
1097        .enumerate()
1098        .find(|(_, part)| matches!(part.content, PartContent::Raw(_) | PartContent::Url(_)))
1099    {
1100        return Err(MessageRefusal::ContentTypeNotSupported {
1101            part_index,
1102            kind: part.content.kind(),
1103            media_type: part.media_type.clone(),
1104        });
1105    }
1106    if configuration
1107        .and_then(|configuration| configuration.get("taskPushNotificationConfig"))
1108        .is_some_and(|config| !config.is_null())
1109    {
1110        return Err(MessageRefusal::PushNotificationNotSupported);
1111    }
1112    Ok(IncomingMessage {
1113        message_id,
1114        context_id,
1115        task_id,
1116        reference_task_ids,
1117        parts,
1118        door_extension: door_metadata.is_some(),
1119        forget_session,
1120        return_immediately,
1121    })
1122}
1123
1124impl IncomingMessage {
1125    /// The message as the agent reads it: each part in order, joined by `"\n"`. A text part is its
1126    /// text; a data part is its value pretty-printed in a code fence labelled `data`, with the
1127    /// part's `mediaType` and `filename` when it has them. No part's `metadata` is rendered.
1128    pub(crate) fn agent_text(&self) -> String {
1129        self.parts
1130            .iter()
1131            .map(|part| match &part.content {
1132                PartContent::Text(text) => text.clone(),
1133                PartContent::Data(value) => {
1134                    let mut label = "data".to_string();
1135                    if let Some(media_type) = &part.media_type {
1136                        label.push_str(&format!(" media-type={}", fence_label_value(media_type)));
1137                    }
1138                    if let Some(filename) = &part.filename {
1139                        label.push_str(&format!(" filename={}", fence_label_value(filename)));
1140                    }
1141                    let body = serde_json::to_string_pretty(value).unwrap_or_default();
1142                    code_fence(&label, &body)
1143                }
1144                PartContent::Raw(_) | PartContent::Url(_) => {
1145                    unreachable!("read_send_params refuses every file part")
1146                }
1147            })
1148            .collect::<Vec<_>>()
1149            .join("\n")
1150    }
1151
1152    /// The kind of each part, in order: `text` or `data`.
1153    pub(crate) fn part_kinds(&self) -> Vec<&'static str> {
1154        self.parts.iter().map(|part| part.content.kind()).collect()
1155    }
1156}
1157
1158/// `body` in a Markdown code fence whose opening line is the fence followed by `label`. The fence
1159/// is a run of backticks one longer than the longest run in `body`, and at least three, so nothing
1160/// in `body` can close it.
1161pub(crate) fn code_fence(label: &str, body: &str) -> String {
1162    let mut longest = 0;
1163    let mut run = 0;
1164    for c in body.chars() {
1165        run = if c == '`' { run + 1 } else { 0 };
1166        longest = longest.max(run);
1167    }
1168    let fence = "`".repeat((longest + 1).max(3));
1169    format!("{fence}{label}\n{body}\n{fence}")
1170}
1171
1172/// `value` as one token of a fence's opening line: whitespace, backticks and `=` become `_`.
1173fn fence_label_value(value: &str) -> String {
1174    value
1175        .chars()
1176        .map(|c| {
1177            if c.is_whitespace() || c == '`' || c == '=' {
1178                '_'
1179            } else {
1180                c
1181            }
1182        })
1183        .collect()
1184}
1185
1186// ── Client helpers ────────────────────────────────────────────────────────────
1187
1188/// The A2A v1.0 message a murmur client sends a door: from `ROLE_USER`, with one text part.
1189/// `contextId` is written only when given.
1190pub(crate) fn user_message(message_id: &str, context_id: Option<&str>, text: &str) -> Value {
1191    let mut message = serde_json::json!({
1192        "messageId": message_id,
1193        "role": Role::User.wire_name(),
1194        "parts": [Part::text(text).to_json()],
1195    });
1196    if let Some(context_id) = context_id {
1197        message["contextId"] = Value::String(context_id.to_string());
1198    }
1199    message
1200}
1201
1202/// The `SendMessage` params a murmur client sends a door: `message` with
1203/// `configuration.returnImmediately: true`, so the door answers once the task is accepted rather
1204/// than once it settles.
1205pub(crate) fn send_message_params(message: Value) -> Value {
1206    serde_json::json!({
1207        "message": message,
1208        "configuration": { "returnImmediately": true },
1209    })
1210}
1211
1212/// A `SendMessageResponse`, as a door's `SendMessage` `result` carries it: the oneof of a task and
1213/// a message.
1214#[derive(Debug, Clone)]
1215pub(crate) enum SendMessageResult {
1216    Task(Box<A2aTask>),
1217    /// The message, as sent. Murmur reads nothing in it.
1218    Message(Value),
1219}
1220
1221impl SendMessageResult {
1222    /// The member `result` sets, or why it is not a `SendMessageResponse`: it sets both members or
1223    /// neither, or its `task` is not a v1.0 task.
1224    pub(crate) fn from_result(result: &Value) -> Result<SendMessageResult, String> {
1225        match send_message_member(result)? {
1226            ("task", task) => serde_json::from_value(task.clone())
1227                .map(|task| SendMessageResult::Task(Box::new(task)))
1228                .map_err(|error| format!("its task is not an A2A task: {error}")),
1229            (_, message) => Ok(SendMessageResult::Message(message.clone())),
1230        }
1231    }
1232}
1233
1234/// Which member of the `SendMessageResponse` oneof `result` sets, `"task"` or `"message"`, with
1235/// its value, or why it sets neither or both.
1236pub(crate) fn send_message_member(result: &Value) -> Result<(&'static str, &Value), String> {
1237    match (result.get("task"), result.get("message")) {
1238        (Some(task), None) => Ok(("task", task)),
1239        (None, Some(message)) => Ok(("message", message)),
1240        (Some(_), Some(_)) => Err("it answered both a task and a message".to_string()),
1241        (None, None) => Err(format!(
1242            "it answered neither a task nor a message: {result}"
1243        )),
1244    }
1245}
1246
1247/// What [`TaskRegistry::request_cancel`] did.
1248///
1249/// Three outcomes and only one of them is an error at the door: cancelling a task that has
1250/// already ended is a clean no-op, because the caller's intent — "do no more work on this" — is
1251/// already true.
1252#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1253pub(crate) enum CancelOutcome {
1254    /// The task was live and is now `Canceled`. Its [`CancelSignal`] has been raised.
1255    Accepted,
1256    /// The task had already reached a terminal state, which is left untouched.
1257    AlreadyTerminal,
1258    /// No task with this id was ever enqueued here.
1259    Unknown,
1260}
1261
1262// ── Task slot state machine ───────────────────────────────────────────────────
1263
1264#[derive(Debug, Clone)]
1265pub(crate) enum TaskSlotState {
1266    Empty,
1267    Running {
1268        task_id: String,
1269        context_id: String,
1270        /// The lane the queue chose this task out of. Set and cleared with the rest of the
1271        /// variant, so it cannot name a lane no task is running in.
1272        lane: TaskLane,
1273    },
1274    /// The last task that ran has ended.
1275    Done,
1276}
1277
1278// ── TaskRegistry — multi-task history tracker ─────────────────────────────────
1279
1280/// Replaces the bare `Arc<Mutex<TaskSlotState>>` at the serve_http boundary.
1281/// Tracks all tasks (active + historical) for queue-mode capsules.
1282pub(crate) struct TaskRegistry {
1283    pub(crate) active_slot: TaskSlotState,
1284    /// All tasks ever enqueued: task_id → (state, context_id)
1285    pub(crate) history: HashMap<String, (TaskState, String)>,
1286    pub(crate) pending_count: usize,
1287    pub(crate) queue_depth: usize,
1288    pub(crate) task_acceptance: TaskAcceptance,
1289    /// Pending input waiters: task_id → (prompt, oneshot sender)
1290    input_waiters: HashMap<String, (String, oneshot::Sender<String>)>,
1291    /// Tasks whose `request-input` wait timed out, until the agent loop takes the mark.
1292    input_timeouts: HashSet<String>,
1293    /// One signal per task anybody has asked about, whether or not it has been cancelled.
1294    ///
1295    /// Minted on demand rather than at enqueue, because both ends need one before the task is
1296    /// running: the task loop watches a task it is about to activate, and the door cancels one
1297    /// that is still queued.
1298    cancels: HashMap<String, CancelSignal>,
1299    /// Which completed turn the capsule's exported files are as of, shared with the resource
1300    /// plane. Lives here because every terminal state passes through this registry, so a third
1301    /// [`Self::finish_task`] call site cannot appear with no matching increment beside it.
1302    resource_generation: Arc<AtomicU64>,
1303    /// Set by [`Self::close_to_new_work`] once the task loop has ended. A closed registry
1304    /// accepts nothing, whatever its acceptance mode and depth.
1305    closed: bool,
1306    /// How each finished task ended, recorded by [`Self::record_ending`].
1307    endings: HashMap<String, TaskEnding>,
1308    /// The door credential that started each task a credential other than the operator's
1309    /// started: a declared credential's name, or `member:<from>` for a formation token.
1310    owners: HashMap<String, String>,
1311    /// The last revision handed out. Every change to a task's state or ending takes the next one.
1312    revision: u64,
1313    /// Each task's revision as of its last change, which orders `ListTasks`, newest first.
1314    revisions: HashMap<String, u64>,
1315    /// The instant of each task's last change, stamped beside its revision: its status's
1316    /// `timestamp`, and what `ListTasks`' `statusTimestampAfter` compares against.
1317    status_times: HashMap<String, DateTime<Utc>>,
1318}
1319
1320/// The `status.message` of a task refused because the session ended before it started.
1321pub(crate) const REJECTED_SESSION_ENDED_MESSAGE: &str =
1322    "task rejected: the session ended before this task started";
1323
1324/// The `status.message` of a task refused because `mur stop` ended the session before it started.
1325pub(crate) const REJECTED_SESSION_STOPPED_MESSAGE: &str =
1326    "task rejected: the session was stopped before this task started";
1327
1328/// The `status.message` the door's `SendStreamingMessage` refusal carries once the registry is closed.
1329pub(crate) const REJECTED_SESSION_CLOSING_MESSAGE: &str = "task rejected: the session is closing";
1330
1331/// The `status.message` of a task refused because the capsule has no room for it. A `call-member`
1332/// call reads exactly this text as "busy" and offers the task again; every other refusal ends it.
1333pub(crate) const REJECTED_BUSY_MESSAGE: &str = "task rejected: capsule is busy";
1334
1335impl TaskRegistry {
1336    pub(crate) fn new(queue_depth: usize, task_acceptance: TaskAcceptance) -> Self {
1337        Self {
1338            active_slot: TaskSlotState::Empty,
1339            history: HashMap::new(),
1340            pending_count: 0,
1341            queue_depth,
1342            task_acceptance,
1343            input_waiters: HashMap::new(),
1344            input_timeouts: HashSet::new(),
1345            cancels: HashMap::new(),
1346            resource_generation: Arc::new(AtomicU64::new(0)),
1347            closed: false,
1348            endings: HashMap::new(),
1349            owners: HashMap::new(),
1350            revision: 0,
1351            revisions: HashMap::new(),
1352            status_times: HashMap::new(),
1353        }
1354    }
1355
1356    /// A handle on the generation counter, for a reader that must not take this registry's lock.
1357    ///
1358    /// The resource plane serves reads while a turn is running, so it holds its own `Arc` and
1359    /// loads it atomically. Taking the registry mutex to answer a `GET` would make a read wait on
1360    /// the agent loop, which is the one thing this plane promises never to do.
1361    pub(crate) fn resource_generation(&self) -> Arc<AtomicU64> {
1362        Arc::clone(&self.resource_generation)
1363    }
1364
1365    /// Advance the generation by one. Called at each [`Self::finish_task`] call site, immediately
1366    /// after the task reaches a terminal state.
1367    ///
1368    /// Provenance, not a pin: it answers "these bytes are as of turn N" and nothing else. No
1369    /// request selects a generation, no response is refused because it moved, and no superseded
1370    /// bytes are retained.
1371    pub(crate) fn advance_resource_generation(&self) {
1372        self.resource_generation.fetch_add(1, Ordering::SeqCst);
1373    }
1374
1375    pub(crate) fn can_accept(&self) -> bool {
1376        if self.closed {
1377            return false;
1378        }
1379        match self.task_acceptance {
1380            TaskAcceptance::None => false,
1381            TaskAcceptance::Single => {
1382                matches!(self.active_slot, TaskSlotState::Empty) && self.pending_count == 0
1383            }
1384            TaskAcceptance::Queue => self.pending_count < self.queue_depth,
1385        }
1386    }
1387
1388    pub(crate) fn enqueue(&mut self, task_id: &str, context_id: &str) {
1389        self.pending_count += 1;
1390        self.record_state(task_id, TaskState::Submitted, context_id.to_string());
1391    }
1392
1393    /// Take back a task [`Self::enqueue`] just enqueued, which the task loop never received:
1394    /// its queue slot, its state, its revision, its status time and its owner.
1395    pub(crate) fn withdraw(&mut self, task_id: &str) {
1396        self.pending_count = self.pending_count.saturating_sub(1);
1397        self.history.remove(task_id);
1398        self.revisions.remove(task_id);
1399        self.status_times.remove(task_id);
1400        self.owners.remove(task_id);
1401    }
1402
1403    /// Record `state` for `task_id` in `context_id`, under the next revision. Every write to
1404    /// [`Self::history`] goes through here, so no state changes without moving the task to the
1405    /// front of `ListTasks`.
1406    pub(crate) fn record_state(&mut self, task_id: &str, state: TaskState, context_id: String) {
1407        self.history
1408            .insert(task_id.to_string(), (state, context_id));
1409        self.touch(task_id);
1410    }
1411
1412    /// Stamp `task_id` with the next revision and the current instant, together, so its status
1413    /// timestamp moves exactly when its place in `ListTasks` does.
1414    fn touch(&mut self, task_id: &str) {
1415        self.revision += 1;
1416        self.revisions.insert(task_id.to_string(), self.revision);
1417        self.status_times.insert(task_id.to_string(), status_now());
1418    }
1419
1420    /// The instant of `task_id`'s last change, or `None` for a task this registry does not hold.
1421    fn status_time(&self, task_id: &str) -> Option<DateTime<Utc>> {
1422        self.status_times.get(task_id).copied()
1423    }
1424
1425    /// Record that the door credential `credential` started `task_id`. Called under the lock the
1426    /// task was enqueued under, so no `GetTask` can see the task without its owner.
1427    pub(crate) fn record_owner(&mut self, task_id: &str, credential: &str) {
1428        self.owners
1429            .insert(task_id.to_string(), credential.to_string());
1430    }
1431
1432    /// The door credential that started `task_id`, or `None` for a task the operator, a caller of
1433    /// a public door or the runtime itself started.
1434    pub(crate) fn owner(&self, task_id: &str) -> Option<&str> {
1435        self.owners.get(task_id).map(String::as_str)
1436    }
1437
1438    /// Record how `task_id` ended: its final status's `message`, and its `response` when it
1439    /// completed. A response on any other state is not kept. `no_answer` is whether it ended
1440    /// through `end-without-answer`, and `no_answer_below` the `(member, status)` of each member
1441    /// further down that gave it no answer.
1442    pub(crate) fn record_ending(
1443        &mut self,
1444        task_id: &str,
1445        message: &str,
1446        response: Option<&str>,
1447        no_answer: bool,
1448        no_answer_below: &[(String, String)],
1449    ) {
1450        let completed = matches!(self.history.get(task_id), Some((TaskState::Completed, _)));
1451        self.endings.insert(
1452            task_id.to_string(),
1453            TaskEnding {
1454                message: message.to_string(),
1455                response: response.filter(|_| completed).map(str::to_string),
1456                no_answer,
1457                no_answer_below: no_answer_below.to_vec(),
1458            },
1459        );
1460        self.touch(task_id);
1461    }
1462
1463    /// `lane` is the lane the queue selected this task out of, so what the registry reports is
1464    /// what the selection did.
1465    pub(crate) fn start_task(&mut self, task_id: String, context_id: String, lane: TaskLane) {
1466        debug_assert!(self.pending_count > 0);
1467        self.pending_count -= 1;
1468        self.record_state(&task_id, TaskState::Working, context_id.clone());
1469        self.active_slot = TaskSlotState::Running {
1470            task_id,
1471            context_id,
1472            lane,
1473        };
1474    }
1475
1476    /// The lane of the task the capsule is running, or `None` when no task is running.
1477    ///
1478    /// `Some` is what stops the lane queue yielding anything, so a `Done` or `Empty` slot must
1479    /// answer `None`.
1480    pub(crate) fn active_lane(&self) -> Option<TaskLane> {
1481        match self.active_slot {
1482            TaskSlotState::Running { lane, .. } => Some(lane),
1483            TaskSlotState::Empty | TaskSlotState::Done => None,
1484        }
1485    }
1486
1487    /// End the running task in `final_state`, and return the state actually recorded: an
1488    /// accepted cancel already in the history wins over whatever is passed. `None` when no task
1489    /// is running, which records nothing.
1490    pub(crate) fn finish_task(&mut self, final_state: TaskState) -> Option<TaskState> {
1491        let TaskSlotState::Running {
1492            ref task_id,
1493            ref context_id,
1494            ..
1495        } = self.active_slot
1496        else {
1497            return None;
1498        };
1499        let (tid, cid) = (task_id.clone(), context_id.clone());
1500        self.input_waiters.remove(&tid);
1501        self.input_timeouts.remove(&tid);
1502        self.cancels.remove(&tid);
1503        // An accepted cancel is final. A turn a person stopped must never later read as one
1504        // that ran to completion, so the outcome the loop reports loses to the one already
1505        // recorded.
1506        let final_state = match self.history.get(&tid) {
1507            Some((TaskState::Canceled, _)) => TaskState::Canceled,
1508            _ => final_state,
1509        };
1510        self.record_state(&tid, final_state.clone(), cid);
1511        self.active_slot = TaskSlotState::Done;
1512        Some(final_state)
1513    }
1514
1515    /// Record that `task_id`'s `request-input` wait passed `lifecycle.input_timeout_secs`
1516    /// without ending the task: the waiter is dropped, an `input-required` task goes back to
1517    /// `working`, and the timeout is marked for [`Self::take_input_timeout`]. The task's
1518    /// terminal state is the reopen loop's to record, once its `on-task-end` hooks have run.
1519    pub(crate) fn record_input_timeout(&mut self, task_id: &str) {
1520        self.input_waiters.remove(task_id);
1521        if let Some((TaskState::InputRequired, ctx)) = self.history.get(task_id).cloned() {
1522            self.record_state(task_id, TaskState::Working, ctx);
1523        }
1524        self.input_timeouts.insert(task_id.to_string());
1525    }
1526
1527    /// Whether `task_id` has an unread input-wait timeout, clearing the mark. Read by the agent
1528    /// loop at each turn boundary, so the attempt that timed out ends and a reopened attempt of
1529    /// the same task does not end on the same mark.
1530    pub(crate) fn take_input_timeout(&mut self, task_id: &str) -> bool {
1531        self.input_timeouts.remove(task_id)
1532    }
1533
1534    /// Transition the active task to InputRequired, storing the prompt and the
1535    /// oneshot sender that will deliver the external response.
1536    pub(crate) fn set_input_required(
1537        &mut self,
1538        task_id: &str,
1539        prompt: String,
1540        tx: oneshot::Sender<String>,
1541    ) -> Result<(), &'static str> {
1542        match &self.active_slot {
1543            TaskSlotState::Running {
1544                task_id: active_id, ..
1545            } if active_id == task_id => {}
1546            _ => return Err("task is not the active running task"),
1547        }
1548        let state = self.history.get(task_id).map(|(s, _)| s);
1549        if !matches!(state, Some(TaskState::Working)) {
1550            return Err("task is not in working state");
1551        }
1552        if let Some((_, ctx)) = self.history.get(task_id).cloned() {
1553            self.record_state(task_id, TaskState::InputRequired, ctx);
1554        }
1555        self.input_waiters.insert(task_id.to_string(), (prompt, tx));
1556        Ok(())
1557    }
1558
1559    /// Deliver external input to an input-required task, transitioning it back to Working.
1560    pub(crate) fn deliver_input(
1561        &mut self,
1562        task_id: &str,
1563        text: String,
1564    ) -> Result<(), &'static str> {
1565        let state = self.history.get(task_id).map(|(s, _)| s);
1566        if !matches!(state, Some(TaskState::InputRequired)) {
1567            return Err("task is not in input-required state");
1568        }
1569        let Some((_, tx)) = self.input_waiters.remove(task_id) else {
1570            return Err("no input waiter found for task");
1571        };
1572        if let Some((_, ctx)) = self.history.get(task_id).cloned() {
1573            self.record_state(task_id, TaskState::Working, ctx);
1574        }
1575        // Sending may fail if the receiver was dropped (timeout path), which is fine.
1576        let _ = tx.send(text);
1577        Ok(())
1578    }
1579
1580    /// The cancel signal for `task_id`, minting one if nobody has asked yet.
1581    ///
1582    /// Handed to whatever has to race against a cancel — the driver call, the input wait, the
1583    /// delegation wait — and to the door, which raises it. A signal for a task that never runs
1584    /// costs one entry and is dropped with the task's terminal state.
1585    pub(crate) fn cancel_watch(&mut self, task_id: &str) -> CancelSignal {
1586        self.cancels.entry(task_id.to_string()).or_default().clone()
1587    }
1588
1589    /// Whether this task's recorded state is `Canceled`.
1590    ///
1591    /// Read at the task loop's activation step, which is what keeps a task cancelled while it was
1592    /// still `submitted` from ever starting.
1593    pub(crate) fn is_canceled(&self, task_id: &str) -> bool {
1594        matches!(self.history.get(task_id), Some((TaskState::Canceled, _)))
1595    }
1596
1597    /// Stop one task: record `Canceled` and raise its signal.
1598    ///
1599    /// Called on the door's connection task and never waits for the agent loop to acknowledge —
1600    /// the state is written here, so a `GetTask` that lands next already reads `canceled`
1601    /// whatever the loop is in the middle of.
1602    ///
1603    /// A cancelled task that was still `submitted` gives its queue slot back immediately: it will
1604    /// be skipped when the loop reaches it, so holding capacity for it would refuse work the
1605    /// capsule can do.
1606    pub(crate) fn request_cancel(&mut self, task_id: &str) -> CancelOutcome {
1607        let Some((state, context_id)) = self.history.get(task_id).cloned() else {
1608            return CancelOutcome::Unknown;
1609        };
1610        if state.is_terminal() {
1611            return CancelOutcome::AlreadyTerminal;
1612        }
1613        if matches!(state, TaskState::Submitted) {
1614            self.pending_count = self.pending_count.saturating_sub(1);
1615        }
1616        self.record_state(task_id, TaskState::Canceled, context_id);
1617        self.cancel_watch(task_id).cancel();
1618        CancelOutcome::Accepted
1619    }
1620
1621    /// Cancel every task that is not yet terminal, and return the ids that were cancelled, sorted.
1622    ///
1623    /// Each goes through [`Self::request_cancel`], so a running task's signal is raised and a
1624    /// `submitted` task's queue slot is given back. A second call returns nothing: every task the
1625    /// first one touched is `Canceled`, which is terminal.
1626    pub(crate) fn cancel_every_live(&mut self) -> Vec<String> {
1627        let live: Vec<String> = self
1628            .history
1629            .iter()
1630            .filter(|(_, (state, _))| !state.is_terminal())
1631            .map(|(task_id, _)| task_id.clone())
1632            .collect();
1633        let mut canceled: Vec<String> = live
1634            .into_iter()
1635            .filter(|task_id| matches!(self.request_cancel(task_id), CancelOutcome::Accepted))
1636            .collect();
1637        // `history` is a `HashMap`, so without this the same two tasks come back in a different
1638        // order on every call and nothing can diff two stops.
1639        canceled.sort();
1640        canceled
1641    }
1642
1643    /// Stop taking work, and end every task still `submitted` in `Rejected`. Returns the refused
1644    /// tasks' `(task_id, context_id)`, sorted by task id, which for UUIDv7 ids is acceptance order.
1645    ///
1646    /// Taken under the same lock the door enqueues under, so no task can be enqueued after the
1647    /// call returns: [`Self::can_accept`] answers `false` from here on. Only `Submitted` tasks are
1648    /// touched. A `Canceled` task keeps its state, and a task that ran keeps the state it ended in.
1649    /// No turn ran, so the resource generation does not advance. A second call returns nothing.
1650    pub(crate) fn close_to_new_work(&mut self) -> Vec<(String, String)> {
1651        self.closed = true;
1652        let mut refused: Vec<(String, String)> = self
1653            .history
1654            .iter()
1655            .filter(|(_, (state, _))| matches!(state, TaskState::Submitted))
1656            .map(|(task_id, (_, context_id))| (task_id.clone(), context_id.clone()))
1657            .collect();
1658        refused.sort();
1659        for (task_id, context_id) in &refused {
1660            self.record_state(task_id, TaskState::Rejected, context_id.clone());
1661            self.cancels.remove(task_id);
1662        }
1663        self.pending_count = self.pending_count.saturating_sub(refused.len());
1664        refused
1665    }
1666
1667    /// Whether [`Self::close_to_new_work`] has run. The door reads it to tell a closing session
1668    /// from a busy one when it refuses a task.
1669    pub(crate) fn is_closed(&self) -> bool {
1670        self.closed
1671    }
1672
1673    /// Whether `task_id`'s ending is recorded, which happens under the lock its terminal state is
1674    /// written under on every path but a cancel, whose ending follows once the task stops.
1675    pub(crate) fn has_ending(&self, task_id: &str) -> bool {
1676        self.endings.contains_key(task_id)
1677    }
1678
1679    /// Return the prompt stored for an input-required task.
1680    #[allow(dead_code)] // used in unit tests
1681    pub(crate) fn get_input_prompt(&self, task_id: &str) -> Option<&str> {
1682        self.input_waiters
1683            .get(task_id)
1684            .map(|(prompt, _)| prompt.as_str())
1685    }
1686
1687    /// `task_id` as `GetTask` reports it.
1688    ///
1689    /// Its `status.timestamp` is the instant of the task's last change. An `input-required` task
1690    /// carries its prompt as a `prompt` artifact. A terminal task carries its final status's
1691    /// message as `status.message`, and a `completed` one its response as a `response` artifact,
1692    /// each when it has one.
1693    pub(crate) fn get_task(&self, task_id: &str) -> Option<A2aTask> {
1694        self.history.get(task_id).map(|(state, context_id)| {
1695            let artifact =
1696                |name: &str, text: &str| A2aArtifact::named(name, vec![Part::text(text)]);
1697            let ending = self.endings.get(task_id).filter(|_| state.is_terminal());
1698            let artifacts = match state {
1699                TaskState::InputRequired => self
1700                    .input_waiters
1701                    .get(task_id)
1702                    .map(|(prompt, _)| vec![artifact(PROMPT_ARTIFACT, prompt)]),
1703                TaskState::Completed => ending
1704                    .and_then(|ending| ending.response.as_deref())
1705                    .filter(|response| !response.is_empty())
1706                    .map(|response| vec![artifact(RESPONSE_ARTIFACT, response)]),
1707                _ => None,
1708            };
1709            let message = ending
1710                .filter(|ending| !ending.message.is_empty())
1711                .map(|ending| StatusMessage::agent(task_id, context_id, &ending.message));
1712            A2aTask {
1713                id: task_id.to_string(),
1714                context_id: context_id.clone(),
1715                status: TaskStatus::at(
1716                    state.clone(),
1717                    message,
1718                    self.status_time(task_id).unwrap_or_else(status_now),
1719                ),
1720                artifacts,
1721                metadata: ending.and_then(TaskEnding::metadata),
1722            }
1723        })
1724    }
1725
1726    /// The block naming each task in `reference_task_ids` as it stands now, appended to a message
1727    /// that references them, or `None` when it references none. `visible` says whether the sender
1728    /// may see a task: one it may not reads exactly as one this registry never held.
1729    ///
1730    /// Each task is one line, `- <id>: <state>` in murmur's word. An ended task other than a
1731    /// `completed` one appends what its final status said; a `completed` one with a response is
1732    /// followed by the response in a code fence labelled `response`.
1733    pub(crate) fn reference_block(
1734        &self,
1735        reference_task_ids: &[String],
1736        visible: impl Fn(&str) -> bool,
1737    ) -> Option<String> {
1738        if reference_task_ids.is_empty() {
1739            return None;
1740        }
1741        let lines: Vec<String> = reference_task_ids
1742            .iter()
1743            .map(|task_id| {
1744                let Some((state, _)) = self.history.get(task_id).filter(|_| visible(task_id))
1745                else {
1746                    return format!("- {task_id}: not a task this capsule holds");
1747                };
1748                let mut line = format!("- {task_id}: {}", state.as_str());
1749                let ending = self.endings.get(task_id).filter(|_| state.is_terminal());
1750                match (state, ending) {
1751                    (TaskState::Completed, Some(ending)) => {
1752                        if let Some(response) =
1753                            ending.response.as_deref().filter(|text| !text.is_empty())
1754                        {
1755                            line.push('\n');
1756                            line.push_str(&code_fence(RESPONSE_ARTIFACT, response));
1757                        }
1758                    }
1759                    (_, Some(ending)) if !ending.message.is_empty() => {
1760                        line.push_str(": ");
1761                        line.push_str(&ending.message.replace(['\r', '\n'], " "));
1762                    }
1763                    _ => {}
1764                }
1765                line
1766            })
1767            .collect();
1768        Some(format!("Referenced tasks:\n{}", lines.join("\n")))
1769    }
1770
1771    /// One `ListTasks` page: the tasks `visible` lets the caller see that match `filter`, newest
1772    /// revision first, the first `page_size` of those strictly after `after`.
1773    ///
1774    /// Each task is exactly [`Self::get_task`]'s. `next` is the position of the page's last task
1775    /// when a matching task lies past it, and `total` counts every visible match, before paging.
1776    /// A task whose state changes during a walk takes a newer revision and moves ahead of the
1777    /// cursor, so a walk returns every match exactly once only while no match changes.
1778    pub(crate) fn list_tasks(
1779        &self,
1780        filter: &TaskFilter,
1781        after: Option<&ListCursor>,
1782        page_size: usize,
1783        visible: impl Fn(&str) -> bool,
1784    ) -> TaskPage {
1785        let mut matching: Vec<ListCursor> = self
1786            .history
1787            .iter()
1788            .filter(|(task_id, (state, context_id))| {
1789                filter.matches(state, context_id, self.status_time(task_id)) && visible(task_id)
1790            })
1791            .map(|(task_id, _)| ListCursor {
1792                revision: self.revisions.get(task_id).copied().unwrap_or_default(),
1793                task_id: task_id.clone(),
1794            })
1795            .collect();
1796        matching.sort_unstable_by(|a, b| b.cmp(a));
1797        let total = matching.len();
1798        let rest: Vec<ListCursor> = matching
1799            .into_iter()
1800            .filter(|position| after.is_none_or(|after| position < after))
1801            .collect();
1802        let page: Vec<&ListCursor> = rest.iter().take(page_size).collect();
1803        let next = (rest.len() > page.len())
1804            .then(|| page.last().map(|last| (*last).clone()))
1805            .flatten();
1806        TaskPage {
1807            tasks: page
1808                .iter()
1809                .filter_map(|position| self.get_task(&position.task_id))
1810                .collect(),
1811            next,
1812            total,
1813        }
1814    }
1815}
1816
1817/// Which tasks a `ListTasks` call asks for.
1818#[derive(Debug, Clone, PartialEq)]
1819pub(crate) struct TaskFilter {
1820    /// Only the tasks in this context, or the tasks of every context when `None`.
1821    pub context_id: Option<String>,
1822    pub state: StateFilter,
1823    /// Only the tasks whose status time is at or after this instant (`statusTimestampAfter`), or
1824    /// every task when `None`.
1825    pub status_after: Option<DateTime<Utc>>,
1826}
1827
1828impl TaskFilter {
1829    fn matches(&self, state: &TaskState, context_id: &str, at: Option<DateTime<Utc>>) -> bool {
1830        let in_context = self.context_id.as_deref().is_none_or(|c| c == context_id);
1831        let in_state = match &self.state {
1832            StateFilter::Any => true,
1833            StateFilter::Only(wanted) => wanted == state,
1834            StateFilter::Nothing => false,
1835        };
1836        let in_time = self
1837            .status_after
1838            .is_none_or(|after| at.is_some_and(|at| at >= after));
1839        in_context && in_state && in_time
1840    }
1841}
1842
1843/// The `status` a `ListTasks` call filters by.
1844#[derive(Debug, Clone, PartialEq)]
1845pub(crate) enum StateFilter {
1846    /// No `status`, or `TASK_STATE_UNSPECIFIED`.
1847    Any,
1848    /// Only tasks in this state.
1849    Only(TaskState),
1850    /// A state no task here is ever in: `TASK_STATE_AUTH_REQUIRED`.
1851    Nothing,
1852}
1853
1854impl StateFilter {
1855    /// The filter a `status` naming the v1.0 `TaskState` `wire` asks for, or `None` when `wire`
1856    /// is not a `TaskState` name.
1857    pub(crate) fn from_wire_name(wire: &str) -> Option<StateFilter> {
1858        match wire {
1859            "TASK_STATE_UNSPECIFIED" => Some(StateFilter::Any),
1860            "TASK_STATE_AUTH_REQUIRED" => Some(StateFilter::Nothing),
1861            wire => TaskState::from_wire_name(wire).map(StateFilter::Only),
1862        }
1863    }
1864}
1865
1866/// A place in the `ListTasks` order: a task's revision and its id. Revisions are unique, so the
1867/// order is total; the id only makes the position self-describing.
1868#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1869pub(crate) struct ListCursor {
1870    pub revision: u64,
1871    pub task_id: String,
1872}
1873
1874impl ListCursor {
1875    /// The `nextPageToken` naming this position: `<revision>:<task_id>` in unpadded base64url.
1876    /// Clients treat it as opaque.
1877    pub(crate) fn encode(&self) -> String {
1878        use base64::Engine as _;
1879        base64::engine::general_purpose::URL_SAFE_NO_PAD
1880            .encode(format!("{}:{}", self.revision, self.task_id))
1881    }
1882
1883    /// The position a `pageToken` names, or `None` for a string [`Self::encode`] did not produce.
1884    pub(crate) fn decode(token: &str) -> Option<ListCursor> {
1885        use base64::Engine as _;
1886        let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
1887            .decode(token)
1888            .ok()?;
1889        let text = String::from_utf8(bytes).ok()?;
1890        let (revision, task_id) = text.split_once(':')?;
1891        let revision = revision
1892            .parse()
1893            .ok()
1894            .filter(|_| !revision.starts_with('+'))?;
1895        (!task_id.is_empty()).then(|| ListCursor {
1896            revision,
1897            task_id: task_id.to_string(),
1898        })
1899    }
1900}
1901
1902/// One `ListTasks` page, as [`TaskRegistry::list_tasks`] returns it.
1903#[derive(Debug, Clone)]
1904pub(crate) struct TaskPage {
1905    pub tasks: Vec<A2aTask>,
1906    /// Where the next page starts, or `None` on the last page.
1907    pub next: Option<ListCursor>,
1908    /// How many tasks the caller sees that match the filter.
1909    pub total: usize,
1910}
1911
1912// ── Incoming task (HTTP server → main thread) ─────────────────────────────────
1913
1914#[derive(Debug)]
1915pub(crate) struct IncomingTask {
1916    pub task_id: String,
1917    pub context_id: String,
1918    pub message_id: String,
1919    pub message_text: String,
1920    pub traceparent: Option<String>,
1921    /// Why this task woke the capsule, classified at the door from the request headers. Every
1922    /// inbound task has one: a caller that claims nothing is an untrusted `event`.
1923    pub provenance: TaskProvenance,
1924    /// Where the task came from, as it appears on its `task_start` record: `"a2a"` for anything
1925    /// that arrived over the peer door, `"detached_shell"` for a completion the runtime produced
1926    /// locally. The `a2a_task_received` record is written only for the former.
1927    pub source: &'static str,
1928    /// Whether the message that started this task asked the capsule to drop the harness session
1929    /// its context names before the turn: [`IncomingMessage::forget_session`]. `false` for every
1930    /// task whose message did not ask, and for every task the runtime enqueued for itself.
1931    pub forget_session: bool,
1932    /// The formation member that called, when the door let this task in on a formation token.
1933    /// Recorded as `a2a_task_received.caller_member`; `None` for every other task.
1934    pub caller_member: Option<String>,
1935    /// The message's `referenceTaskIds`, each once, recorded as `task_start.reference_task_ids`.
1936    /// Empty for every task that did not arrive over the door.
1937    pub reference_task_ids: Vec<String>,
1938    /// The kind of each part of the message, recorded as `task_start.part_kinds`. Empty for every
1939    /// task that did not arrive over the door.
1940    pub part_kinds: Vec<&'static str>,
1941}
1942
1943/// The `source` of a task that arrived over the A2A door.
1944pub(crate) const SOURCE_A2A: &str = "a2a";
1945
1946/// The `source` of a task the runtime enqueued for itself when a demoted shell command finished.
1947pub(crate) const SOURCE_DETACHED_SHELL: &str = "detached_shell";
1948
1949/// The `source` of a task a resumed launch enqueued for itself to report demoted commands the
1950/// session it resumes never accounted for. Distinct from [`SOURCE_DETACHED_SHELL`] because the
1951/// two say opposite things: one carries a result, the other says no result exists.
1952pub(crate) const SOURCE_DETACHED_LOST: &str = "detached_lost";
1953
1954// ── Unit tests ────────────────────────────────────────────────────────────────
1955
1956#[cfg(test)]
1957mod tests {
1958    use super::*;
1959
1960    fn make_registry() -> TaskRegistry {
1961        TaskRegistry::new(1, TaskAcceptance::Single)
1962    }
1963
1964    fn running_registry(task_id: &str) -> TaskRegistry {
1965        let mut r = make_registry();
1966        r.enqueue(task_id, "ctx_001");
1967        r.start_task(task_id.to_string(), "ctx_001".to_string(), TaskLane::Bg);
1968        r
1969    }
1970
1971    #[test]
1972    fn set_input_required_transitions_state() {
1973        let mut r = running_registry("tsk_001");
1974        let (tx, _rx) = oneshot::channel();
1975        assert!(r
1976            .set_input_required("tsk_001", "which branch?".into(), tx)
1977            .is_ok());
1978        let task = r.get_task("tsk_001").unwrap();
1979        assert_eq!(task.status.state, TaskState::InputRequired);
1980        let artifacts = task.artifacts.unwrap();
1981        assert_eq!(artifacts[0].artifact_id, "prompt");
1982        assert_eq!(artifacts[0].first_text(), Some("which branch?"));
1983    }
1984
1985    #[test]
1986    fn set_input_required_fails_for_wrong_task() {
1987        let mut r = running_registry("tsk_001");
1988        let (tx, _rx) = oneshot::channel();
1989        assert!(r
1990            .set_input_required("tsk_other", "prompt".into(), tx)
1991            .is_err());
1992    }
1993
1994    #[test]
1995    fn deliver_input_transitions_back_to_working() {
1996        let mut r = running_registry("tsk_001");
1997        let (tx, mut rx) = oneshot::channel();
1998        r.set_input_required("tsk_001", "which branch?".into(), tx)
1999            .unwrap();
2000        r.deliver_input("tsk_001", "main".to_string()).unwrap();
2001        // Oneshot should have received the value
2002        assert_eq!(rx.try_recv().unwrap(), "main");
2003        let task = r.get_task("tsk_001").unwrap();
2004        assert_eq!(task.status.state, TaskState::Working);
2005        assert!(task.artifacts.is_none());
2006    }
2007
2008    #[test]
2009    fn deliver_input_fails_when_not_input_required() {
2010        let mut r = running_registry("tsk_001");
2011        assert!(r.deliver_input("tsk_001", "text".into()).is_err());
2012    }
2013
2014    #[test]
2015    fn an_input_required_task_carries_its_prompt_and_nothing_else() {
2016        let mut r = running_registry("tsk_1");
2017        let (tx, _rx) = oneshot::channel();
2018        r.set_input_required("tsk_1", "Which file?".to_string(), tx)
2019            .unwrap();
2020        let task = r.get_task("tsk_1").unwrap();
2021        let artifacts = task.artifacts.unwrap();
2022        assert_eq!(artifacts.len(), 1);
2023        assert_eq!(
2024            serde_json::to_value(&artifacts[0]).unwrap(),
2025            serde_json::json!({"artifactId": "prompt", "name": "prompt",
2026                "parts": [{"text": "Which file?", "mediaType": "text/plain"}]})
2027        );
2028        assert!(task.status.message.is_none());
2029    }
2030
2031    /// A live task carries nothing beyond its state: no artifact and no status message.
2032    #[test]
2033    fn a_working_task_carries_no_artifact_and_no_message() {
2034        let r = running_registry("tsk_1");
2035        let task = serde_json::to_value(r.get_task("tsk_1").unwrap()).unwrap();
2036        assert_bare_status(&task["status"], "TASK_STATE_WORKING");
2037        assert_eq!(
2038            task,
2039            serde_json::json!({"id": "tsk_1", "contextId": "ctx_001", "status": {
2040                "state": "TASK_STATE_WORKING",
2041                "timestamp": timestamp_of(&r, "tsk_1"),
2042            }})
2043        );
2044    }
2045
2046    /// A completed task answers with its response as the `response` artifact and its final
2047    /// message as `status.message`, an A2A message from the agent.
2048    #[test]
2049    fn a_completed_task_carries_its_response_artifact_and_status_message() {
2050        let mut r = running_registry("tsk_1");
2051        r.finish_task(TaskState::Completed);
2052        r.record_ending("tsk_1", "done", Some("the answer is 4"), false, &[]);
2053        let task = serde_json::to_value(r.get_task("tsk_1").unwrap()).unwrap();
2054        assert_eq!(
2055            task,
2056            serde_json::json!({
2057                "id": "tsk_1",
2058                "contextId": "ctx_001",
2059                "status": {
2060                    "state": "TASK_STATE_COMPLETED",
2061                    "message": {
2062                        "messageId": "msg_tsk_1_status",
2063                        "contextId": "ctx_001",
2064                        "taskId": "tsk_1",
2065                        "role": "ROLE_AGENT",
2066                        "parts": [{"text": "done", "mediaType": "text/plain"}],
2067                    },
2068                    "timestamp": status_timestamp(r.status_times["tsk_1"]),
2069                },
2070                "artifacts": [{
2071                    "artifactId": "response",
2072                    "name": "response",
2073                    "parts": [{"text": "the answer is 4", "mediaType": "text/plain"}],
2074                }],
2075            })
2076        );
2077    }
2078
2079    /// A task ended through `end-without-answer` carries `metadata.murmur.noAnswer` and the
2080    /// members further down that gave it none; a completed task with members missing below it
2081    /// carries its response and `noAnswerBelow` but no `noAnswer`; any other ending carries no
2082    /// `metadata` at all.
2083    #[test]
2084    fn a_no_answer_ending_carries_its_metadata_and_no_other_ending_does() {
2085        let below = [("q".to_string(), "timed_out".to_string())];
2086        let mut r = running_registry("tsk_1");
2087        r.finish_task(TaskState::Failed);
2088        r.record_ending("tsk_1", "q gave no answer", None, true, &below);
2089        let task = serde_json::to_value(r.get_task("tsk_1").unwrap()).unwrap();
2090        assert_eq!(task["status"]["state"], "TASK_STATE_FAILED");
2091        assert_eq!(
2092            task["status"]["message"]["parts"][0]["text"],
2093            "q gave no answer"
2094        );
2095        assert_eq!(
2096            task["metadata"],
2097            serde_json::json!({"murmur": {
2098                "noAnswer": true,
2099                "noAnswerBelow": [{"member": "q", "status": "timed_out"}],
2100            }})
2101        );
2102
2103        let mut r = running_registry("tsk_2");
2104        r.finish_task(TaskState::Completed);
2105        r.record_ending("tsk_2", "session ended", Some("none came"), false, &below);
2106        let task = serde_json::to_value(r.get_task("tsk_2").unwrap()).unwrap();
2107        assert_eq!(
2108            task["artifacts"],
2109            serde_json::json!([{"artifactId": "response", "name": "response",
2110                "parts": [{"text": "none came", "mediaType": "text/plain"}]}])
2111        );
2112        assert_eq!(
2113            task["metadata"],
2114            serde_json::json!({"murmur": {
2115                "noAnswerBelow": [{"member": "q", "status": "timed_out"}],
2116            }})
2117        );
2118
2119        for state in [TaskState::Completed, TaskState::Failed] {
2120            let mut r = running_registry("tsk_3");
2121            r.finish_task(state);
2122            r.record_ending("tsk_3", "ended", Some("4"), false, &[]);
2123            let task = serde_json::to_value(r.get_task("tsk_3").unwrap()).unwrap();
2124            assert!(task.get("metadata").is_none(), "{task}");
2125        }
2126        let r = running_registry("tsk_4");
2127        let task = serde_json::to_value(r.get_task("tsk_4").unwrap()).unwrap();
2128        assert!(task.get("metadata").is_none(), "{task}");
2129    }
2130
2131    /// A task that did not complete carries its message and no response, whatever was passed.
2132    #[test]
2133    fn a_failed_task_carries_its_status_message_and_no_response() {
2134        let mut r = running_registry("tsk_1");
2135        r.finish_task(TaskState::Failed);
2136        r.record_ending("tsk_1", "the driver failed", Some("partial"), false, &[]);
2137        let task = r.get_task("tsk_1").unwrap();
2138        assert!(task.artifacts.is_none());
2139        let message = task.status.message.unwrap();
2140        assert_eq!(message.role, Role::Agent);
2141        assert_eq!(message.text(), Some("the driver failed"));
2142    }
2143
2144    /// A completed task with no response text, or no recorded ending, carries no artifact.
2145    #[test]
2146    fn a_completed_task_without_a_response_carries_no_artifact() {
2147        let mut r = running_registry("tsk_1");
2148        r.finish_task(TaskState::Completed);
2149        assert!(r.get_task("tsk_1").unwrap().artifacts.is_none());
2150        r.record_ending("tsk_1", "ok", Some(""), false, &[]);
2151        let task = r.get_task("tsk_1").unwrap();
2152        assert!(task.artifacts.is_none());
2153        assert_eq!(task.status.message.unwrap().text(), Some("ok"));
2154    }
2155
2156    #[test]
2157    fn an_owner_is_recorded_per_task() {
2158        let mut r = make_registry();
2159        r.enqueue("tsk_a", "ctx_001");
2160        r.record_owner("tsk_a", "member:lead");
2161        r.enqueue("tsk_b", "ctx_001");
2162        assert_eq!(r.owner("tsk_a"), Some("member:lead"));
2163        assert_eq!(r.owner("tsk_b"), None);
2164        r.withdraw("tsk_a");
2165        assert_eq!(r.owner("tsk_a"), None);
2166        assert!(r.get_task("tsk_a").is_none());
2167        assert_eq!(r.pending_count, 1);
2168    }
2169
2170    /// The ids of a page, in order.
2171    fn listed(page: &TaskPage) -> Vec<&str> {
2172        page.tasks.iter().map(|task| task.id.as_str()).collect()
2173    }
2174
2175    fn every_task() -> TaskFilter {
2176        TaskFilter {
2177            context_id: None,
2178            state: StateFilter::Any,
2179            status_after: None,
2180        }
2181    }
2182
2183    /// Every change to a task's state or ending moves it to the front of the listing.
2184    #[test]
2185    fn list_tasks_orders_by_the_last_change_newest_first() {
2186        let mut r = TaskRegistry::new(8, TaskAcceptance::Queue);
2187        for id in ["tsk_a", "tsk_b", "tsk_c"] {
2188            r.enqueue(id, "ctx_001");
2189        }
2190        let all = |r: &TaskRegistry| -> Vec<String> {
2191            listed(&r.list_tasks(&every_task(), None, 10, |_| true))
2192                .into_iter()
2193                .map(str::to_string)
2194                .collect()
2195        };
2196        assert_eq!(all(&r), ["tsk_c", "tsk_b", "tsk_a"]);
2197        r.start_task("tsk_a".to_string(), "ctx_001".to_string(), TaskLane::User);
2198        assert_eq!(all(&r), ["tsk_a", "tsk_c", "tsk_b"]);
2199        r.request_cancel("tsk_b");
2200        assert_eq!(all(&r), ["tsk_b", "tsk_a", "tsk_c"]);
2201        let (tx, _rx) = oneshot::channel();
2202        r.set_input_required("tsk_a", "which?".to_string(), tx)
2203            .unwrap();
2204        assert_eq!(all(&r), ["tsk_a", "tsk_b", "tsk_c"]);
2205        r.deliver_input("tsk_a", "left".to_string()).unwrap();
2206        r.finish_task(TaskState::Completed);
2207        r.record_ending("tsk_c", "", None, false, &[]);
2208        assert_eq!(all(&r), ["tsk_c", "tsk_a", "tsk_b"]);
2209        r.record_ending("tsk_a", "done", Some("four"), false, &[]);
2210        assert_eq!(all(&r), ["tsk_a", "tsk_c", "tsk_b"]);
2211        r.close_to_new_work();
2212        assert_eq!(all(&r), ["tsk_c", "tsk_a", "tsk_b"], "tsk_c was rejected");
2213
2214        let page = r.list_tasks(&every_task(), None, 10, |_| true);
2215        for task in &page.tasks {
2216            assert_eq!(
2217                serde_json::to_value(task).unwrap(),
2218                serde_json::to_value(r.get_task(&task.id).unwrap()).unwrap()
2219            );
2220        }
2221    }
2222
2223    /// Pages follow one another through the cursor; a task that changes during the walk moves
2224    /// ahead of the cursor and is not returned a second time.
2225    #[test]
2226    fn list_tasks_walks_pages_and_returns_a_task_changed_mid_walk_once() {
2227        let mut r = TaskRegistry::new(8, TaskAcceptance::Queue);
2228        for id in ["tsk_1", "tsk_2", "tsk_3", "tsk_4", "tsk_5"] {
2229            r.enqueue(id, "ctx_001");
2230        }
2231        let first = r.list_tasks(&every_task(), None, 2, |_| true);
2232        assert_eq!(listed(&first), ["tsk_5", "tsk_4"]);
2233        assert_eq!(first.total, 5);
2234        let cursor = first.next.clone().expect("more tasks lie past the page");
2235        assert_eq!(ListCursor::decode(&cursor.encode()), Some(cursor.clone()));
2236
2237        r.request_cancel("tsk_4");
2238        r.request_cancel("tsk_2");
2239        let second = r.list_tasks(&every_task(), Some(&cursor), 2, |_| true);
2240        assert_eq!(listed(&second), ["tsk_3", "tsk_1"], "tsk_2 moved ahead");
2241        assert_eq!(second.next, None);
2242        assert_eq!(second.total, 5);
2243        let fresh = r.list_tasks(&every_task(), None, 2, |_| true);
2244        assert_eq!(listed(&fresh), ["tsk_2", "tsk_4"]);
2245
2246        let exact = r.list_tasks(&every_task(), None, 5, |_| true);
2247        assert_eq!(exact.tasks.len(), 5);
2248        assert_eq!(
2249            exact.next, None,
2250            "no task lies past a page that ends the list"
2251        );
2252    }
2253
2254    /// The instant a status timestamp names, parsed back.
2255    fn instant(timestamp: &str) -> DateTime<Utc> {
2256        DateTime::parse_from_rfc3339(timestamp)
2257            .unwrap()
2258            .with_timezone(&Utc)
2259    }
2260
2261    fn timestamp_of(r: &TaskRegistry, task_id: &str) -> String {
2262        r.get_task(task_id).unwrap().status.timestamp.unwrap()
2263    }
2264
2265    /// Long enough that two stamps taken either side of it differ at millisecond precision.
2266    fn a_tick() {
2267        std::thread::sleep(std::time::Duration::from_millis(3));
2268    }
2269
2270    /// The status timestamp and the revision are stamped together: each change moves both, and
2271    /// nothing else moves either.
2272    #[test]
2273    fn a_status_timestamp_moves_with_the_revision() {
2274        let mut r = TaskRegistry::new(8, TaskAcceptance::Queue);
2275        r.enqueue("tsk_a", "ctx_001");
2276        let submitted = timestamp_of(&r, "tsk_a");
2277        let submitted_revision = r.revisions["tsk_a"];
2278        assert_eq!(r.status_times["tsk_a"], instant(&submitted));
2279
2280        a_tick();
2281        r.record_owner("tsk_a", "alice");
2282        assert_eq!(
2283            timestamp_of(&r, "tsk_a"),
2284            submitted,
2285            "an owner is no change"
2286        );
2287        assert_eq!(r.revisions["tsk_a"], submitted_revision);
2288
2289        r.start_task("tsk_a".to_string(), "ctx_001".to_string(), TaskLane::User);
2290        let working = timestamp_of(&r, "tsk_a");
2291        assert!(r.revisions["tsk_a"] > submitted_revision);
2292        assert!(
2293            instant(&working) > instant(&submitted),
2294            "{submitted} → {working}"
2295        );
2296
2297        a_tick();
2298        r.finish_task(TaskState::Completed);
2299        let completed_revision = r.revisions["tsk_a"];
2300        let completed = timestamp_of(&r, "tsk_a");
2301        assert!(instant(&completed) > instant(&working));
2302
2303        a_tick();
2304        r.record_ending("tsk_a", "done", Some("four"), false, &[]);
2305        assert!(r.revisions["tsk_a"] > completed_revision);
2306        assert!(instant(&timestamp_of(&r, "tsk_a")) > instant(&completed));
2307    }
2308
2309    /// GetTask's string is the stored instant exactly, so a cutoff copied from it includes the
2310    /// task: the comparison is "at or after".
2311    #[test]
2312    fn a_status_timestamp_round_trips_and_a_cutoff_equal_to_it_includes_the_task() {
2313        let mut r = TaskRegistry::new(8, TaskAcceptance::Queue);
2314        r.enqueue("tsk_a", "ctx_001");
2315        let timestamp = timestamp_of(&r, "tsk_a");
2316        assert!(timestamp.ends_with('Z'), "{timestamp}");
2317        assert_eq!(
2318            timestamp.len(),
2319            "2026-10-13T09:00:00.123Z".len(),
2320            "{timestamp}"
2321        );
2322        assert_eq!(instant(&timestamp), r.status_times["tsk_a"]);
2323        let at = TaskFilter {
2324            status_after: Some(instant(&timestamp)),
2325            ..every_task()
2326        };
2327        assert_eq!(listed(&r.list_tasks(&at, None, 10, |_| true)), ["tsk_a"]);
2328        let after = TaskFilter {
2329            status_after: Some(instant(&timestamp) + chrono::Duration::milliseconds(1)),
2330            ..every_task()
2331        };
2332        assert!(r.list_tasks(&after, None, 10, |_| true).tasks.is_empty());
2333    }
2334
2335    #[test]
2336    fn withdraw_drops_the_status_time() {
2337        let mut r = TaskRegistry::new(8, TaskAcceptance::Queue);
2338        r.enqueue("tsk_a", "ctx_001");
2339        r.withdraw("tsk_a");
2340        assert!(!r.status_times.contains_key("tsk_a"));
2341        assert!(r.get_task("tsk_a").is_none());
2342    }
2343
2344    /// `status_after` filters before paging, so `total` counts only the tasks it keeps and the
2345    /// cursor walks only those; it combines with the other filters.
2346    #[test]
2347    fn list_tasks_filters_by_status_time_before_paging() {
2348        let mut r = TaskRegistry::new(8, TaskAcceptance::Queue);
2349        for id in ["tsk_1", "tsk_2", "tsk_3", "tsk_4"] {
2350            r.enqueue(id, "ctx_001");
2351        }
2352        r.request_cancel("tsk_3");
2353        let base = instant("2026-10-13T09:00:00Z");
2354        for (offset, id) in ["tsk_1", "tsk_2", "tsk_3", "tsk_4"].iter().enumerate() {
2355            r.status_times.insert(
2356                id.to_string(),
2357                base + chrono::Duration::seconds(offset as i64),
2358            );
2359        }
2360        let since = |seconds: i64| TaskFilter {
2361            status_after: Some(base + chrono::Duration::seconds(seconds)),
2362            ..every_task()
2363        };
2364        let first = r.list_tasks(&since(1), None, 2, |_| true);
2365        assert_eq!(listed(&first), ["tsk_3", "tsk_4"]);
2366        assert_eq!(first.total, 3);
2367        let second = r.list_tasks(&since(1), first.next.as_ref(), 2, |_| true);
2368        assert_eq!(listed(&second), ["tsk_2"]);
2369        assert_eq!((second.total, second.next), (3, None));
2370
2371        let canceled_since = TaskFilter {
2372            state: StateFilter::Only(TaskState::Canceled),
2373            ..since(1)
2374        };
2375        let page = r.list_tasks(&canceled_since, None, 10, |_| true);
2376        assert_eq!((listed(&page), page.total), (vec!["tsk_3"], 1));
2377        assert_eq!(r.list_tasks(&since(4), None, 10, |_| true).total, 0);
2378        assert_eq!(r.list_tasks(&every_task(), None, 10, |_| true).total, 4);
2379    }
2380
2381    /// The filters and the visibility predicate decide the page and the total alike.
2382    #[test]
2383    fn list_tasks_filters_by_context_state_and_visibility() {
2384        let mut r = TaskRegistry::new(8, TaskAcceptance::Queue);
2385        r.enqueue("tsk_a", "ctx_one");
2386        r.record_owner("tsk_a", "alice");
2387        r.enqueue("tsk_b", "ctx_two");
2388        r.record_owner("tsk_b", "alice");
2389        r.enqueue("tsk_c", "ctx_one");
2390        r.record_owner("tsk_c", "bob");
2391        r.request_cancel("tsk_c");
2392        let alice = |task_id: &str| r.owner(task_id) == Some("alice");
2393        let page = r.list_tasks(&every_task(), None, 10, alice);
2394        assert_eq!((listed(&page), page.total), (vec!["tsk_b", "tsk_a"], 2));
2395        let in_one = TaskFilter {
2396            context_id: Some("ctx_one".to_string()),
2397            state: StateFilter::Any,
2398            status_after: None,
2399        };
2400        let page = r.list_tasks(&in_one, None, 10, |_| true);
2401        assert_eq!((listed(&page), page.total), (vec!["tsk_c", "tsk_a"], 2));
2402        let canceled = TaskFilter {
2403            context_id: None,
2404            state: StateFilter::Only(TaskState::Canceled),
2405            status_after: None,
2406        };
2407        let page = r.list_tasks(&canceled, None, 10, |_| true);
2408        assert_eq!(listed(&page), ["tsk_c"]);
2409        let page = r.list_tasks(&canceled, None, 10, alice);
2410        assert_eq!((page.tasks.len(), page.total, page.next), (0, 0, None));
2411        let nothing = TaskFilter {
2412            context_id: None,
2413            state: StateFilter::Nothing,
2414            status_after: None,
2415        };
2416        assert_eq!(r.list_tasks(&nothing, None, 10, |_| true).total, 0);
2417    }
2418
2419    #[test]
2420    fn list_cursor_reads_only_what_it_wrote() {
2421        let cursor = ListCursor {
2422            revision: 42,
2423            task_id: "tsk_01a1".to_string(),
2424        };
2425        let token = cursor.encode();
2426        assert!(!token.contains('='), "{token}");
2427        assert_eq!(ListCursor::decode(&token), Some(cursor));
2428        use base64::Engine as _;
2429        let encode = |text: &str| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(text);
2430        for garbage in [
2431            "not-a-token".to_string(),
2432            "".to_string(),
2433            "!!".to_string(),
2434            encode("42"),
2435            encode("42:"),
2436            encode("x:tsk_1"),
2437            encode("-1:tsk_1"),
2438            encode("+1:tsk_1"),
2439        ] {
2440            assert_eq!(ListCursor::decode(&garbage), None, "{garbage}");
2441        }
2442    }
2443
2444    #[test]
2445    fn list_state_filter_reads_every_v1_state_name() {
2446        for state in TaskState::ALL {
2447            assert_eq!(
2448                TaskState::from_wire_name(state.wire_name()),
2449                Some(state.clone())
2450            );
2451            assert_eq!(
2452                StateFilter::from_wire_name(state.wire_name()),
2453                Some(StateFilter::Only(state))
2454            );
2455        }
2456        assert_eq!(
2457            StateFilter::from_wire_name("TASK_STATE_UNSPECIFIED"),
2458            Some(StateFilter::Any)
2459        );
2460        assert_eq!(
2461            StateFilter::from_wire_name("TASK_STATE_AUTH_REQUIRED"),
2462            Some(StateFilter::Nothing)
2463        );
2464        for other in ["done", "completed", "task_state_completed", ""] {
2465            assert_eq!(StateFilter::from_wire_name(other), None, "{other}");
2466            assert_eq!(TaskState::from_wire_name(other), None, "{other}");
2467        }
2468    }
2469
2470    #[test]
2471    fn request_cancel_on_a_running_task_records_canceled() {
2472        let mut r = running_registry("tsk_001");
2473        let signal = r.cancel_watch("tsk_001");
2474        assert_eq!(r.request_cancel("tsk_001"), CancelOutcome::Accepted);
2475        assert!(signal.is_canceled());
2476        assert!(r.is_canceled("tsk_001"));
2477        let task = r.get_task("tsk_001").unwrap();
2478        assert_eq!(task.status.state, TaskState::Canceled);
2479    }
2480
2481    #[test]
2482    fn request_cancel_on_a_completed_task_leaves_it_alone() {
2483        let mut r = running_registry("tsk_001");
2484        r.finish_task(TaskState::Completed);
2485        assert_eq!(r.request_cancel("tsk_001"), CancelOutcome::AlreadyTerminal);
2486        assert_eq!(
2487            r.get_task("tsk_001").unwrap().status.state,
2488            TaskState::Completed
2489        );
2490        // And a second cancel of an already-cancelled task says the same thing.
2491        let mut r = running_registry("tsk_002");
2492        assert_eq!(r.request_cancel("tsk_002"), CancelOutcome::Accepted);
2493        assert_eq!(r.request_cancel("tsk_002"), CancelOutcome::AlreadyTerminal);
2494    }
2495
2496    #[test]
2497    fn cancel_every_live_cancels_live_tasks_in_order_and_leaves_terminal_ones() {
2498        let mut r = TaskRegistry::new(8, TaskAcceptance::Queue);
2499        r.enqueue("tsk_c", "ctx_001");
2500        r.enqueue("tsk_a", "ctx_001");
2501        r.enqueue("tsk_done", "ctx_001");
2502        r.start_task("tsk_done".to_string(), "ctx_001".to_string(), TaskLane::Bg);
2503        r.finish_task(TaskState::Completed);
2504        r.start_task("tsk_a".to_string(), "ctx_001".to_string(), TaskLane::Bg);
2505        let running = r.cancel_watch("tsk_a");
2506
2507        assert_eq!(r.cancel_every_live(), vec!["tsk_a", "tsk_c"]);
2508        assert!(running.is_canceled());
2509        assert!(r.is_canceled("tsk_c"));
2510        assert_eq!(
2511            r.get_task("tsk_done").unwrap().status.state,
2512            TaskState::Completed
2513        );
2514        assert!(r.cancel_every_live().is_empty());
2515    }
2516
2517    #[test]
2518    fn request_cancel_on_an_unknown_id_is_unknown() {
2519        let mut r = make_registry();
2520        assert_eq!(r.request_cancel("tsk_doesnotexist"), CancelOutcome::Unknown);
2521        assert!(!r.is_canceled("tsk_doesnotexist"));
2522    }
2523
2524    #[test]
2525    fn finish_task_does_not_overwrite_an_accepted_cancel() {
2526        let mut r = running_registry("tsk_001");
2527        assert_eq!(r.request_cancel("tsk_001"), CancelOutcome::Accepted);
2528        assert_eq!(
2529            r.finish_task(TaskState::Completed),
2530            Some(TaskState::Canceled),
2531            "the state recorded is the accepted cancel, and the caller is told so"
2532        );
2533        assert_eq!(
2534            r.get_task("tsk_001").unwrap().status.state,
2535            TaskState::Canceled
2536        );
2537        assert_eq!(
2538            r.finish_task(TaskState::Failed),
2539            None,
2540            "a slot that is no longer running records nothing"
2541        );
2542        assert_eq!(
2543            r.get_task("tsk_001").unwrap().status.state,
2544            TaskState::Canceled
2545        );
2546    }
2547
2548    #[test]
2549    fn finish_task_returns_the_state_it_recorded() {
2550        let mut r = running_registry("tsk_001");
2551        assert_eq!(
2552            r.finish_task(TaskState::Completed),
2553            Some(TaskState::Completed)
2554        );
2555        assert_eq!(make_registry().finish_task(TaskState::Failed), None);
2556    }
2557
2558    #[test]
2559    fn an_input_timeout_leaves_the_task_working_and_is_read_once() {
2560        let mut r = running_registry("tsk_001");
2561        let (tx, _rx) = oneshot::channel();
2562        r.set_input_required("tsk_001", "prompt".into(), tx)
2563            .unwrap();
2564        r.record_input_timeout("tsk_001");
2565        assert_eq!(
2566            r.get_task("tsk_001").unwrap().status.state,
2567            TaskState::Working,
2568            "the task's terminal state is not the wait's to record"
2569        );
2570        assert!(
2571            r.get_input_prompt("tsk_001").is_none(),
2572            "the waiter is gone"
2573        );
2574        assert!(r.take_input_timeout("tsk_001"));
2575        assert!(
2576            !r.take_input_timeout("tsk_001"),
2577            "a reopened attempt does not end on the same timeout"
2578        );
2579        assert!(!r.take_input_timeout("tsk_other"));
2580    }
2581
2582    #[test]
2583    fn cancelling_a_queued_task_frees_its_queue_slot() {
2584        let mut r = TaskRegistry::new(2, TaskAcceptance::Queue);
2585        r.enqueue("tsk_a", "ctx_001");
2586        r.enqueue("tsk_b", "ctx_001");
2587        assert!(!r.can_accept(), "the queue is full");
2588        assert_eq!(r.request_cancel("tsk_b"), CancelOutcome::Accepted);
2589        assert!(r.can_accept(), "a cancelled task holds no slot");
2590    }
2591
2592    #[test]
2593    fn close_to_new_work_rejects_every_submitted_task_and_nothing_else() {
2594        let mut r = TaskRegistry::new(4, TaskAcceptance::Queue);
2595        for id in ["tsk_a", "tsk_b", "tsk_c", "tsk_d"] {
2596            r.enqueue(id, &format!("ctx_{id}"));
2597        }
2598        r.start_task("tsk_a".to_string(), "ctx_tsk_a".to_string(), TaskLane::Bg);
2599        let (tx, _rx) = oneshot::channel();
2600        r.set_input_required("tsk_a", "prompt".into(), tx).unwrap();
2601        assert_eq!(r.request_cancel("tsk_c"), CancelOutcome::Accepted);
2602        let pending_before = r.pending_count;
2603
2604        let refused = r.close_to_new_work();
2605
2606        assert_eq!(
2607            refused,
2608            vec![
2609                ("tsk_b".to_string(), "ctx_tsk_b".to_string()),
2610                ("tsk_d".to_string(), "ctx_tsk_d".to_string()),
2611            ]
2612        );
2613        for id in ["tsk_b", "tsk_d"] {
2614            assert_eq!(r.get_task(id).unwrap().status.state, TaskState::Rejected);
2615        }
2616        assert_eq!(r.pending_count, pending_before - 2);
2617        assert_eq!(r.pending_count, 0);
2618        assert_eq!(
2619            r.get_task("tsk_a").unwrap().status.state,
2620            TaskState::InputRequired,
2621            "a live task that is not submitted is left alone"
2622        );
2623        assert_eq!(
2624            r.get_task("tsk_c").unwrap().status.state,
2625            TaskState::Canceled
2626        );
2627        assert_eq!(r.request_cancel("tsk_b"), CancelOutcome::AlreadyTerminal);
2628        assert!(!r.is_canceled("tsk_b"));
2629    }
2630
2631    #[test]
2632    fn a_closed_registry_accepts_nothing_and_closes_once() {
2633        for (depth, mode) in [(1, TaskAcceptance::Single), (4, TaskAcceptance::Queue)] {
2634            let mut r = TaskRegistry::new(depth, mode.clone());
2635            assert!(r.can_accept());
2636            assert!(!r.is_closed());
2637            r.enqueue("tsk_a", "ctx_001");
2638            assert_eq!(r.close_to_new_work().len(), 1);
2639            assert!(r.is_closed());
2640            assert!(!r.can_accept(), "{mode:?}: a closed registry refuses work");
2641            assert!(r.close_to_new_work().is_empty(), "{mode:?}: closes once");
2642            assert!(!r.can_accept());
2643        }
2644    }
2645
2646    /// `LifecycleConfig::tasks_held_at_once` is what `W-ROS-001` compares a member's callers
2647    /// against; this registry is what actually rejects a call. Filling a registry the way the
2648    /// door and the task loop do must accept exactly that many tasks, so a change to either rule
2649    /// fails here instead of leaving the warning wrong.
2650    #[test]
2651    fn tasks_held_at_once_matches_what_the_registry_accepts() {
2652        const SAFETY_CAP: usize = 64;
2653        for (mode, depth, expected) in [
2654            (TaskAcceptance::None, 1, 0),
2655            (TaskAcceptance::Single, 1, 1),
2656            (TaskAcceptance::Queue, 1, 2),
2657            (TaskAcceptance::Queue, 3, 4),
2658        ] {
2659            let mut r = TaskRegistry::new(depth, mode.clone());
2660            let mut accepted = 0;
2661            while r.can_accept() && accepted < SAFETY_CAP {
2662                let task_id = format!("tsk_{accepted}");
2663                r.enqueue(&task_id, "ctx_001");
2664                if accepted == 0 {
2665                    r.start_task(task_id, "ctx_001".to_string(), TaskLane::Peer);
2666                }
2667                accepted += 1;
2668            }
2669            let lifecycle = murmur_artifact::LifecycleConfig {
2670                task_acceptance: mode.clone(),
2671                queue_depth: depth,
2672                ..Default::default()
2673            };
2674            assert_eq!(accepted, expected, "{mode:?} at depth {depth}");
2675            assert_eq!(
2676                accepted,
2677                lifecycle.tasks_held_at_once(),
2678                "{mode:?} at depth {depth}: tasks_held_at_once drifted from the registry"
2679            );
2680        }
2681    }
2682
2683    #[test]
2684    fn task_state_serializes_as_its_wire_name_and_reads_only_that() {
2685        for state in TaskState::ALL {
2686            let wire = serde_json::to_value(&state).unwrap();
2687            assert_eq!(wire, serde_json::json!(state.wire_name()));
2688            assert_eq!(
2689                serde_json::from_value::<TaskState>(wire).unwrap(),
2690                state,
2691                "{state:?}"
2692            );
2693            assert!(
2694                serde_json::from_value::<TaskState>(serde_json::json!(state.as_str())).is_err(),
2695                "{state:?}: murmur's word is not a wire state"
2696            );
2697        }
2698        for refused in [
2699            serde_json::json!("TASK_STATE_UNSPECIFIED"),
2700            serde_json::json!("TASK_STATE_AUTH_REQUIRED"),
2701            serde_json::json!("task_state_working"),
2702            serde_json::json!(1),
2703            serde_json::json!(null),
2704        ] {
2705            assert!(
2706                serde_json::from_value::<TaskState>(refused.clone()).is_err(),
2707                "{refused}"
2708            );
2709        }
2710    }
2711
2712    #[test]
2713    fn task_state_as_str_is_murmurs_word() {
2714        let words: Vec<&str> = TaskState::ALL.iter().map(TaskState::as_str).collect();
2715        assert_eq!(
2716            words,
2717            [
2718                "submitted",
2719                "working",
2720                "completed",
2721                "failed",
2722                "canceled",
2723                "input-required",
2724                "rejected"
2725            ]
2726        );
2727    }
2728
2729    #[test]
2730    fn murmur_state_name_maps_the_eight_v1_states_and_nothing_else() {
2731        for state in TaskState::ALL {
2732            assert_eq!(murmur_state_name(state.wire_name()), Some(state.as_str()));
2733        }
2734        assert_eq!(
2735            murmur_state_name("TASK_STATE_AUTH_REQUIRED"),
2736            Some("auth-required")
2737        );
2738        for other in [
2739            "TASK_STATE_UNSPECIFIED",
2740            "working",
2741            "",
2742            "TASK_STATE_working",
2743        ] {
2744            assert_eq!(murmur_state_name(other), None, "{other:?}");
2745        }
2746    }
2747
2748    #[test]
2749    fn roles_serialize_as_their_proto_names() {
2750        assert_eq!(
2751            serde_json::to_value(Role::User).unwrap(),
2752            serde_json::json!("ROLE_USER")
2753        );
2754        assert_eq!(
2755            serde_json::to_value(Role::Agent).unwrap(),
2756            serde_json::json!("ROLE_AGENT")
2757        );
2758        assert!(serde_json::from_value::<Role>(serde_json::json!("agent")).is_err());
2759    }
2760
2761    #[test]
2762    fn finish_task_cleans_up_input_waiters() {
2763        let mut r = running_registry("tsk_001");
2764        let (tx, _rx) = oneshot::channel();
2765        r.set_input_required("tsk_001", "prompt".into(), tx)
2766            .unwrap();
2767        r.finish_task(TaskState::Failed);
2768        // input_waiters should be cleaned up
2769        assert!(r.get_input_prompt("tsk_001").is_none());
2770    }
2771
2772    #[test]
2773    fn a2a_error_table_is_the_spec_table_in_code_order() {
2774        let table: Vec<(i32, &str)> = A2aError::ALL
2775            .into_iter()
2776            .map(|error| (error.code(), error.reason()))
2777            .collect();
2778        assert_eq!(
2779            table,
2780            [
2781                (-32001, "TASK_NOT_FOUND"),
2782                (-32002, "TASK_NOT_CANCELABLE"),
2783                (-32003, "PUSH_NOTIFICATION_NOT_SUPPORTED"),
2784                (-32004, "UNSUPPORTED_OPERATION"),
2785                (-32005, "CONTENT_TYPE_NOT_SUPPORTED"),
2786                (-32006, "INVALID_AGENT_RESPONSE"),
2787                (-32007, "EXTENDED_AGENT_CARD_NOT_CONFIGURED"),
2788                (-32008, "EXTENSION_SUPPORT_REQUIRED"),
2789                (-32009, "VERSION_NOT_SUPPORTED"),
2790            ]
2791        );
2792    }
2793
2794    #[test]
2795    fn a2a_error_from_code_round_trips_and_knows_no_other_code() {
2796        for error in A2aError::ALL {
2797            assert_eq!(A2aError::from_code(error.code()), Some(error));
2798        }
2799        for code in [-32000, -32010, -32099, -32600, -31001, 0] {
2800            assert_eq!(A2aError::from_code(code), None, "{code}");
2801        }
2802    }
2803
2804    #[test]
2805    fn a2a_murmur_errors_lie_outside_the_reserved_range() {
2806        let table: Vec<(i32, &str)> = MurmurError::ALL
2807            .into_iter()
2808            .map(|error| (error.code(), error.reason()))
2809            .collect();
2810        assert_eq!(
2811            table,
2812            [
2813                (-31001, "COMPLETION_MISADDRESSED"),
2814                (-31002, "COMPLETION_NOT_AWAITED")
2815            ]
2816        );
2817        for error in MurmurError::ALL {
2818            assert!(!(-32768..=-32000).contains(&error.code()), "{error:?}");
2819        }
2820    }
2821
2822    fn wire(response: &JsonRpcResponse) -> Value {
2823        serde_json::to_value(response).unwrap()
2824    }
2825
2826    #[test]
2827    fn a2a_error_carries_one_error_info_with_string_metadata() {
2828        let response = JsonRpcResponse::a2a_error(
2829            Value::from(7),
2830            A2aError::TaskNotCancelable,
2831            "Task cannot be canceled",
2832            &[("taskId", "tsk_1".into()), ("state", "completed".into())],
2833        );
2834        assert_eq!(
2835            wire(&response),
2836            serde_json::json!({
2837                "jsonrpc": "2.0",
2838                "id": 7,
2839                "error": {
2840                    "code": -32002,
2841                    "message": "Task cannot be canceled",
2842                    "data": [{
2843                        "@type": "type.googleapis.com/google.rpc.ErrorInfo",
2844                        "reason": "TASK_NOT_CANCELABLE",
2845                        "domain": "a2a-protocol.org",
2846                        "metadata": {"taskId": "tsk_1", "state": "completed"},
2847                    }],
2848                },
2849            })
2850        );
2851    }
2852
2853    #[test]
2854    fn a2a_murmur_error_is_in_the_murmur_domain() {
2855        let response = JsonRpcResponse::murmur_error(
2856            Value::from("a"),
2857            MurmurError::CompletionNotAwaited,
2858            "nobody waits",
2859            &[],
2860        );
2861        let error = &wire(&response)["error"];
2862        assert_eq!(error["code"], -31002);
2863        assert_eq!(error["data"][0]["reason"], "COMPLETION_NOT_AWAITED");
2864        assert_eq!(error["data"][0]["domain"], "murmur.nexus");
2865        assert_eq!(error["data"][0]["metadata"], serde_json::json!({}));
2866    }
2867
2868    #[test]
2869    fn a2a_standard_errors_carry_no_data() {
2870        let error = &wire(&JsonRpcResponse::err(
2871            Value::Null,
2872            -32601,
2873            "Method not found",
2874        ))["error"];
2875        assert!(error.get("data").is_none(), "{error}");
2876    }
2877
2878    fn refusal(body: &str) -> Value {
2879        wire(&JsonRpcRequest::from_body(body).expect_err(body))
2880    }
2881
2882    #[test]
2883    fn a2a_from_body_reads_a_request_and_defaults_params() {
2884        let req = JsonRpcRequest::from_body(
2885            r#"{"jsonrpc":"2.0","id":"x","method":"GetExtendedAgentCard","extra":1}"#,
2886        )
2887        .unwrap();
2888        assert_eq!(req.id, Value::from("x"));
2889        assert_eq!(req.method, "GetExtendedAgentCard");
2890        assert_eq!(req.params, serde_json::json!({}));
2891
2892        let req = JsonRpcRequest::from_body(
2893            r#"{"jsonrpc":"2.0","id":3,"method":"GetTask","params":[1]}"#,
2894        )
2895        .unwrap();
2896        assert_eq!(req.id, Value::from(3));
2897        assert_eq!(req.params, serde_json::json!([1]), "a method judges params");
2898    }
2899
2900    #[test]
2901    fn a2a_from_body_refuses_what_is_not_json() {
2902        let answer = refusal("{not json");
2903        assert_eq!(answer["id"], Value::Null);
2904        assert_eq!(answer["error"]["code"], -32700);
2905        assert_eq!(answer["error"]["message"], "Invalid JSON payload");
2906    }
2907
2908    #[test]
2909    fn a2a_from_body_refuses_what_is_not_a_json_rpc_request() {
2910        for (body, id) in [
2911            (
2912                r#"{"jsonrpc":"1.0","id":1,"method":"GetTask"}"#,
2913                Value::from(1),
2914            ),
2915            (r#"{"id":1,"method":"GetTask"}"#, Value::from(1)),
2916            (
2917                r#"{"jsonrpc":2.0,"id":1,"method":"GetTask"}"#,
2918                Value::from(1),
2919            ),
2920            (
2921                r#"[{"jsonrpc":"2.0","id":1,"method":"GetTask"}]"#,
2922                Value::Null,
2923            ),
2924            (r#""GetTask""#, Value::Null),
2925            (r#"{"jsonrpc":"2.0","id":"a","method":7}"#, Value::from("a")),
2926            (r#"{"jsonrpc":"2.0","id":"a"}"#, Value::from("a")),
2927            (
2928                r#"{"jsonrpc":"2.0","id":{"a":1},"method":"GetTask"}"#,
2929                Value::Null,
2930            ),
2931            (
2932                r#"{"jsonrpc":"2.0","id":null,"method":"GetTask"}"#,
2933                Value::Null,
2934            ),
2935            (
2936                r#"{"jsonrpc":"2.0","id":1.5,"method":"GetTask"}"#,
2937                Value::Null,
2938            ),
2939            (r#"{"jsonrpc":"2.0","method":"GetTask"}"#, Value::Null),
2940        ] {
2941            let answer = refusal(body);
2942            assert_eq!(answer["error"]["code"], -32600, "{body}");
2943            assert_eq!(
2944                answer["error"]["message"], "Request payload validation error",
2945                "{body}"
2946            );
2947            assert_eq!(answer["id"], id, "{body}");
2948        }
2949    }
2950
2951    fn versions(values: &[&str]) -> Vec<String> {
2952        values.iter().map(|value| value.to_string()).collect()
2953    }
2954
2955    #[test]
2956    fn a2a_version_accepts_1_0_with_or_without_a_patch() {
2957        for value in ["1.0", " 1.0 ", "1.0.0", "1.0.1", "1.0.17"] {
2958            assert!(accepts_a2a_version(&versions(&[value])), "{value:?}");
2959        }
2960    }
2961
2962    #[test]
2963    fn a2a_version_refuses_every_other_value_and_more_than_one_line() {
2964        for values in [
2965            &[][..],
2966            &[""],
2967            &["0.3"],
2968            &["0.3.0"],
2969            &["2.0"],
2970            &["1"],
2971            &["1.1"],
2972            &["1.0x"],
2973            &["1.0."],
2974            &["1.0.a"],
2975            &["1.00"],
2976            &["v1.0"],
2977            &["1.0", "1.0"],
2978        ] {
2979            assert!(!accepts_a2a_version(&versions(values)), "{values:?}");
2980        }
2981    }
2982
2983    #[test]
2984    fn an_extension_is_activated_by_an_exact_entry_on_any_line() {
2985        const URI: &str =
2986            "https://docs.murmur.nexus/reference/streaming-protocol/#murmur-stream-v1";
2987        let other = "https://example.com/x/v1";
2988        for values in [
2989            vec![URI.to_string()],
2990            vec![format!(" {URI} ")],
2991            vec![format!("{other}, {URI}")],
2992            vec![format!("{other},,{URI},")],
2993            vec![other.to_string(), URI.to_string()],
2994        ] {
2995            assert!(activates_extension(&values, URI), "{values:?}");
2996        }
2997        for values in [
2998            vec![],
2999            vec![String::new()],
3000            vec![other.to_string()],
3001            vec![format!("{URI}/")],
3002            vec![URI.to_uppercase()],
3003            vec![format!("{URI} {other}")],
3004        ] {
3005            assert!(!activates_extension(&values, URI), "{values:?}");
3006        }
3007    }
3008
3009    #[test]
3010    fn a2a_version_refusal_names_what_was_sent() {
3011        let answer = wire(&version_not_supported(Value::from(4), &versions(&[])));
3012        assert_eq!(answer["id"], 4);
3013        assert_eq!(answer["error"]["code"], -32009);
3014        let info = &answer["error"]["data"][0];
3015        assert_eq!(info["reason"], "VERSION_NOT_SUPPORTED");
3016        assert_eq!(info["domain"], "a2a-protocol.org");
3017        assert_eq!(
3018            info["metadata"],
3019            serde_json::json!({"requestedVersion": "", "supportedVersions": "1.0"})
3020        );
3021
3022        let answer = wire(&version_not_supported(Value::Null, &versions(&[" 0.3 "])));
3023        assert_eq!(
3024            answer["error"]["data"][0]["metadata"]["requestedVersion"],
3025            "0.3"
3026        );
3027        let answer = wire(&version_not_supported(
3028            Value::Null,
3029            &versions(&["1.0", "1.0"]),
3030        ));
3031        assert_eq!(
3032            answer["error"]["data"][0]["metadata"]["requestedVersion"],
3033            "1.0, 1.0"
3034        );
3035    }
3036
3037    // ── Parts and incoming messages ──────────────────────────────────────────
3038
3039    #[test]
3040    fn a_part_serializes_its_one_content_field_and_reads_back() {
3041        for (part, wire) in [
3042            (
3043                Part::text("hi"),
3044                serde_json::json!({"text": "hi", "mediaType": "text/plain"}),
3045            ),
3046            (
3047                Part::data(serde_json::json!({"a": [1]})),
3048                serde_json::json!({"data": {"a": [1]}, "mediaType": "application/json"}),
3049            ),
3050            (
3051                Part::data(Value::Null),
3052                serde_json::json!({"data": null, "mediaType": "application/json"}),
3053            ),
3054        ] {
3055            assert_eq!(serde_json::to_value(&part).unwrap(), wire);
3056            assert_eq!(serde_json::from_value::<Part>(wire).unwrap(), part);
3057        }
3058        for refused in [
3059            serde_json::json!({}),
3060            serde_json::json!({"text": "a", "data": 1}),
3061            serde_json::json!({"kind": "file", "file": {"uri": "x"}}),
3062            serde_json::json!({"text": 7}),
3063            serde_json::json!("text"),
3064        ] {
3065            assert!(
3066                serde_json::from_value::<Part>(refused.clone()).is_err(),
3067                "{refused}"
3068            );
3069        }
3070    }
3071
3072    fn params(message: Value) -> Value {
3073        serde_json::json!({ "message": message })
3074    }
3075
3076    fn user(parts: Value) -> Value {
3077        serde_json::json!({"messageId": "msg_1", "role": "ROLE_USER", "parts": parts})
3078    }
3079
3080    fn invalid_params(params: &Value) -> String {
3081        match read_send_params(params) {
3082            Err(MessageRefusal::InvalidParams(message)) => message,
3083            other => panic!("expected -32602 for {params}, got {other:?}"),
3084        }
3085    }
3086
3087    #[test]
3088    fn read_send_params_reads_a_v1_message_in_full() {
3089        let message = read_send_params(&params(serde_json::json!({
3090            "messageId": "msg_1",
3091            "contextId": "ctx_1",
3092            "taskId": "tsk_1",
3093            "role": "ROLE_USER",
3094            "referenceTaskIds": ["tsk_a", "tsk_b", "tsk_a"],
3095            "parts": [{"text": "a"}, {"data": null, "filename": "f.json"}],
3096            "metadata": {"ignored": true},
3097            "extensions": ["https://example.com/x"],
3098            "unknown": 1,
3099        })))
3100        .unwrap();
3101        assert_eq!(message.message_id, "msg_1");
3102        assert_eq!(message.context_id.as_deref(), Some("ctx_1"));
3103        assert_eq!(message.task_id.as_deref(), Some("tsk_1"));
3104        assert_eq!(message.reference_task_ids, ["tsk_a", "tsk_b"]);
3105        assert_eq!(message.part_kinds(), ["text", "data"]);
3106        assert_eq!(message.parts[1].filename.as_deref(), Some("f.json"));
3107
3108        let message = read_send_params(&params(serde_json::json!({
3109            "messageId": "msg_1", "role": "ROLE_USER", "taskId": "", "parts": [{"text": "a"}],
3110        })))
3111        .unwrap();
3112        assert_eq!(message.task_id, None, "an empty taskId names no task");
3113    }
3114
3115    /// Each structural check refuses with `-32602` in the documented order, so a message wrong in
3116    /// two ways learns about the earlier one.
3117    #[test]
3118    fn read_send_params_refuses_structure_in_order() {
3119        let text = serde_json::json!([{"text": "a"}]);
3120        for (params, expected) in [
3121            (serde_json::json!({}), "params.message"),
3122            (user(text.clone()), "params.message"),
3123            (serde_json::json!({"message": "hi"}), "params.message"),
3124            (
3125                params(serde_json::json!({"role": "ROLE_USER", "parts": text})),
3126                "messageId",
3127            ),
3128            (
3129                params(serde_json::json!({"messageId": "", "role": "ROLE_USER", "parts": text})),
3130                "messageId",
3131            ),
3132            (
3133                params(serde_json::json!({"messageId": "m", "role": "user", "parts": []})),
3134                "ROLE_USER",
3135            ),
3136            (
3137                params(serde_json::json!({"messageId": "m", "role": "ROLE_AGENT", "parts": text})),
3138                "ROLE_USER",
3139            ),
3140            (
3141                params(serde_json::json!({"messageId": "m", "role": 1, "parts": text})),
3142                "ROLE_USER",
3143            ),
3144            (
3145                params(serde_json::json!({"messageId": "m", "parts": text})),
3146                "ROLE_USER",
3147            ),
3148            (params(user(serde_json::json!([]))), "non-empty array"),
3149            (
3150                params(user(serde_json::json!({"text": "a"}))),
3151                "non-empty array",
3152            ),
3153            (
3154                params(user(serde_json::json!([{"text": "a"}, {}]))),
3155                "message.parts[1] sets none",
3156            ),
3157            (
3158                params(user(serde_json::json!([{"text": "a", "data": 1}]))),
3159                "message.parts[0] sets text and data",
3160            ),
3161            (
3162                params(user(
3163                    serde_json::json!([{"kind": "file", "file": {"uri": "x"}}]),
3164                )),
3165                "message.parts[0] sets none",
3166            ),
3167            (
3168                params(user(serde_json::json!([{"url": 7}, {}]))),
3169                "message.parts[1] sets none",
3170            ),
3171            (
3172                params(user(serde_json::json!([{"text": 7}]))),
3173                "message.parts[0].text is not a string",
3174            ),
3175            (
3176                params(user(serde_json::json!([{"data": 1, "mediaType": 2}]))),
3177                "message.parts[0].mediaType",
3178            ),
3179            (
3180                params(serde_json::json!({"messageId": "m", "role": "ROLE_USER",
3181                    "parts": [{"url": "u"}], "taskId": 7})),
3182                "message.taskId",
3183            ),
3184            (
3185                params(serde_json::json!({"messageId": "m", "role": "ROLE_USER",
3186                    "parts": text, "contextId": ["c"]})),
3187                "message.contextId",
3188            ),
3189            (
3190                params(serde_json::json!({"messageId": "m", "role": "ROLE_USER",
3191                    "parts": [{"raw": "aGk="}], "referenceTaskIds": [""]})),
3192                "referenceTaskIds",
3193            ),
3194            (
3195                params(serde_json::json!({"messageId": "m", "role": "ROLE_USER",
3196                    "parts": text, "referenceTaskIds": [7]})),
3197                "referenceTaskIds",
3198            ),
3199            (
3200                params(serde_json::json!({"messageId": "m", "role": "ROLE_USER",
3201                    "parts": text, "referenceTaskIds": "tsk_a"})),
3202                "referenceTaskIds",
3203            ),
3204        ] {
3205            let message = invalid_params(&params);
3206            assert!(message.contains(expected), "{params}: {message}");
3207        }
3208    }
3209
3210    #[test]
3211    fn read_send_params_caps_distinct_references() {
3212        let ids = |n: usize| -> Vec<String> { (0..n).map(|i| format!("tsk_{i}")).collect() };
3213        let with = |ids: Vec<String>| {
3214            params(serde_json::json!({"messageId": "m", "role": "ROLE_USER",
3215                "parts": [{"text": "a"}], "referenceTaskIds": ids}))
3216        };
3217        let mut sixteen_twice = ids(MAX_REFERENCE_TASK_IDS);
3218        sixteen_twice.extend(ids(MAX_REFERENCE_TASK_IDS));
3219        assert_eq!(
3220            read_send_params(&with(sixteen_twice))
3221                .unwrap()
3222                .reference_task_ids,
3223            ids(MAX_REFERENCE_TASK_IDS)
3224        );
3225        let message = invalid_params(&with(ids(MAX_REFERENCE_TASK_IDS + 1)));
3226        assert!(message.contains("names 17 tasks"), "{message}");
3227    }
3228
3229    /// A message asking for push notifications is refused, whatever else `configuration` holds;
3230    /// a `null` config asks for nothing.
3231    #[test]
3232    fn read_send_params_refuses_a_push_notification_config() {
3233        let message = user(serde_json::json!([{"text": "hi"}]));
3234        let asking = serde_json::json!({"message": message,
3235            "configuration": {"taskPushNotificationConfig": {"url": "https://example.com/hook"}}});
3236        assert_eq!(
3237            read_send_params(&asking),
3238            Err(MessageRefusal::PushNotificationNotSupported)
3239        );
3240        let answer = serde_json::to_value(
3241            MessageRefusal::PushNotificationNotSupported.into_response(Value::from(1)),
3242        )
3243        .unwrap();
3244        assert_eq!(answer["error"]["code"], -32003);
3245        assert_eq!(
3246            answer["error"]["message"],
3247            "push notifications are not supported by this agent"
3248        );
3249        assert_eq!(
3250            answer["error"]["data"][0],
3251            serde_json::json!({"@type": "type.googleapis.com/google.rpc.ErrorInfo",
3252                "reason": "PUSH_NOTIFICATION_NOT_SUPPORTED", "domain": "a2a-protocol.org",
3253                "metadata": {}})
3254        );
3255        for configuration in [
3256            serde_json::json!({"taskPushNotificationConfig": null}),
3257            serde_json::json!({"acceptedOutputModes": ["text/plain"], "historyLength": 2}),
3258            serde_json::json!("ignored"),
3259        ] {
3260            let params = serde_json::json!({"message": message, "configuration": configuration});
3261            assert!(read_send_params(&params).is_ok(), "{params}");
3262        }
3263    }
3264
3265    /// A message whose `metadata` is `metadata`, naming `task_id` when given.
3266    fn with_metadata(metadata: Value, task_id: Option<&str>) -> Value {
3267        let mut message = user(serde_json::json!([{"text": "a"}]));
3268        message["metadata"] = metadata;
3269        if let Some(task_id) = task_id {
3270            message["taskId"] = Value::from(task_id);
3271        }
3272        params(message)
3273    }
3274
3275    /// `metadata` keyed by the door extension's URI, holding `door`.
3276    fn door(door: Value) -> Value {
3277        serde_json::json!({ DOOR_EXTENSION_URI: door })
3278    }
3279
3280    #[test]
3281    fn read_send_params_reads_forget_session_true() {
3282        let message = read_send_params(&with_metadata(
3283            door(serde_json::json!({"forgetSession": true})),
3284            None,
3285        ))
3286        .unwrap();
3287        assert!(message.forget_session);
3288        assert!(message.door_extension);
3289    }
3290
3291    #[test]
3292    fn read_send_params_reads_every_other_metadata_as_no_forget() {
3293        for (metadata, door_extension) in [
3294            (door(serde_json::json!({"forgetSession": false})), true),
3295            (door(serde_json::json!({})), true),
3296            (
3297                door(serde_json::json!({"other": 1, "forgetSession": false})),
3298                true,
3299            ),
3300            (serde_json::json!({}), false),
3301            (
3302                serde_json::json!({"https://example.com/x": {"forgetSession": true}}),
3303                false,
3304            ),
3305            (serde_json::json!({"forgetSession": true}), false),
3306            (serde_json::json!("not an object"), false),
3307            (
3308                serde_json::json!([door(serde_json::json!({"forgetSession": true}))]),
3309                false,
3310            ),
3311            (Value::Null, false),
3312        ] {
3313            let message = read_send_params(&with_metadata(metadata.clone(), None)).unwrap();
3314            assert!(!message.forget_session, "{metadata}");
3315            assert_eq!(message.door_extension, door_extension, "{metadata}");
3316        }
3317        let message = read_send_params(&params(user(serde_json::json!([{"text": "a"}])))).unwrap();
3318        assert!(
3319            !message.forget_session && !message.door_extension,
3320            "no metadata at all"
3321        );
3322    }
3323
3324    #[test]
3325    fn read_send_params_ignores_other_keys_beside_forget_session() {
3326        let message = read_send_params(&with_metadata(
3327            door(serde_json::json!({"forgetSession": true, "later": {"x": 1}})),
3328            None,
3329        ))
3330        .unwrap();
3331        assert!(message.forget_session);
3332    }
3333
3334    #[test]
3335    fn read_send_params_refuses_a_door_extension_value_that_is_not_an_object() {
3336        for value in [
3337            serde_json::json!(true),
3338            serde_json::json!("forget"),
3339            serde_json::json!([]),
3340        ] {
3341            assert_eq!(
3342                invalid_params(&with_metadata(door(value), None)),
3343                format!("message.metadata[\"{DOOR_EXTENSION_URI}\"] must be an object")
3344            );
3345        }
3346    }
3347
3348    #[test]
3349    fn read_send_params_refuses_a_forget_session_that_is_not_a_boolean() {
3350        for value in [serde_json::json!("true"), serde_json::json!(1), Value::Null] {
3351            assert_eq!(
3352                invalid_params(&with_metadata(
3353                    door(serde_json::json!({"forgetSession": value})),
3354                    None
3355                )),
3356                format!(
3357                    "message.metadata[\"{DOOR_EXTENSION_URI}\"].forgetSession must be a boolean"
3358                )
3359            );
3360        }
3361    }
3362
3363    #[test]
3364    fn read_send_params_refuses_a_forget_on_a_message_that_continues_a_task() {
3365        assert_eq!(
3366            invalid_params(&with_metadata(
3367                door(serde_json::json!({"forgetSession": true})),
3368                Some("tsk_1")
3369            )),
3370            "forgetSession starts a new conversation, so it goes on a message that starts a task, \
3371             not on one that continues task tsk_1"
3372        );
3373        // `false` asks for nothing, so it may ride on a continuation.
3374        let message = read_send_params(&with_metadata(
3375            door(serde_json::json!({"forgetSession": false})),
3376            Some("tsk_1"),
3377        ))
3378        .unwrap();
3379        assert_eq!(message.task_id.as_deref(), Some("tsk_1"));
3380        assert!(!message.forget_session);
3381    }
3382
3383    /// The door extension's checks come after every structural check and before the file-part
3384    /// refusal, each refused with `-32602` in order.
3385    #[test]
3386    fn read_send_params_checks_the_forget_field_in_order() {
3387        // A structural fault earlier in the message is reported first.
3388        let mut message = with_metadata(door(Value::from(1)), None);
3389        message["message"]["referenceTaskIds"] = Value::from("tsk_a");
3390        assert!(invalid_params(&message).contains("referenceTaskIds"));
3391        // Not an object beats the boolean and continuation checks (they cannot be reached).
3392        let message = with_metadata(door(Value::from(1)), Some("tsk_1"));
3393        assert!(invalid_params(&message).ends_with("must be an object"));
3394        // Not a boolean beats the continuation check.
3395        let message = with_metadata(
3396            door(serde_json::json!({"forgetSession": "yes"})),
3397            Some("tsk_1"),
3398        );
3399        assert!(invalid_params(&message).ends_with("must be a boolean"));
3400        // A malformed forget beats a file part.
3401        let mut message = with_metadata(door(serde_json::json!({"forgetSession": 1})), None);
3402        message["message"]["parts"] = serde_json::json!([{"raw": "aGk="}]);
3403        assert!(invalid_params(&message).ends_with("must be a boolean"));
3404        // A well-formed forget on a message with a file part is refused for the part.
3405        let mut message = with_metadata(door(serde_json::json!({"forgetSession": true})), None);
3406        message["message"]["parts"] = serde_json::json!([{"raw": "aGk="}]);
3407        assert!(matches!(
3408            read_send_params(&message),
3409            Err(MessageRefusal::ContentTypeNotSupported { .. })
3410        ));
3411        // Each renders as `Invalid params: …`.
3412        let refusal = read_send_params(&with_metadata(door(Value::from(1)), None)).unwrap_err();
3413        let answer = wire(&refusal.into_response(Value::from(3)));
3414        assert_eq!(answer["error"]["code"], -32602);
3415        assert_eq!(
3416            answer["error"]["message"],
3417            format!("Invalid params: message.metadata[\"{DOOR_EXTENSION_URI}\"] must be an object")
3418        );
3419    }
3420
3421    /// `params` for a well-formed message with `configuration` as given.
3422    fn configured(configuration: Value) -> Value {
3423        serde_json::json!({
3424            "message": user(serde_json::json!([{"text": "a"}])),
3425            "configuration": configuration,
3426        })
3427    }
3428
3429    #[test]
3430    fn read_send_params_reads_return_immediately_true_only_for_true() {
3431        let immediately = read_send_params(&configured(serde_json::json!({
3432            "returnImmediately": true,
3433        })))
3434        .unwrap();
3435        assert!(immediately.return_immediately);
3436        for params in [
3437            params(user(serde_json::json!([{"text": "a"}]))),
3438            configured(Value::Null),
3439            configured(serde_json::json!({})),
3440            configured(serde_json::json!({"returnImmediately": null})),
3441            configured(serde_json::json!({"returnImmediately": false})),
3442            configured(serde_json::json!({"historyLength": 2})),
3443            configured(serde_json::json!("ignored")),
3444        ] {
3445            assert!(
3446                !read_send_params(&params).unwrap().return_immediately,
3447                "{params}"
3448            );
3449        }
3450    }
3451
3452    #[test]
3453    fn read_send_params_refuses_a_return_immediately_that_is_not_a_boolean() {
3454        for value in [
3455            Value::from("yes"),
3456            Value::from(1),
3457            serde_json::json!({}),
3458            serde_json::json!([true]),
3459        ] {
3460            let params = configured(serde_json::json!({ "returnImmediately": value }));
3461            assert_eq!(
3462                invalid_params(&params),
3463                "configuration.returnImmediately must be a boolean",
3464                "{params}"
3465            );
3466            let answer = wire(
3467                &read_send_params(&params)
3468                    .unwrap_err()
3469                    .into_response(1.into()),
3470            );
3471            assert_eq!(answer["error"]["code"], -32602);
3472            assert_eq!(
3473                answer["error"]["message"],
3474                "Invalid params: configuration.returnImmediately must be a boolean"
3475            );
3476        }
3477    }
3478
3479    /// The flag is the last `-32602` check: after the door extension's, before the file part and
3480    /// the push config.
3481    #[test]
3482    fn read_send_params_checks_return_immediately_in_order() {
3483        let mut message = with_metadata(door(serde_json::json!({"forgetSession": 1})), None);
3484        message["configuration"] = serde_json::json!({"returnImmediately": "yes"});
3485        assert!(invalid_params(&message).contains("forgetSession"));
3486
3487        let mut message = configured(serde_json::json!({
3488            "returnImmediately": "yes",
3489            "taskPushNotificationConfig": {"url": "https://example.com/hook"},
3490        }));
3491        message["message"]["parts"] = serde_json::json!([{"raw": "aGk="}]);
3492        assert!(invalid_params(&message).contains("returnImmediately"));
3493
3494        message["configuration"]["returnImmediately"] = Value::Bool(true);
3495        assert!(matches!(
3496            read_send_params(&message),
3497            Err(MessageRefusal::ContentTypeNotSupported { .. })
3498        ));
3499    }
3500
3501    /// A file part is refused only once the message is otherwise well formed, naming the first.
3502    #[test]
3503    fn read_send_params_refuses_the_first_file_part_with_content_type_not_supported() {
3504        assert_eq!(
3505            read_send_params(&params(user(serde_json::json!([
3506                {"text": "a"},
3507                {"url": "https://example.com/x.pdf", "mediaType": "application/pdf"},
3508                {"raw": "aGk="},
3509            ])))),
3510            Err(MessageRefusal::ContentTypeNotSupported {
3511                part_index: 1,
3512                kind: "url",
3513                media_type: Some("application/pdf".to_string()),
3514            })
3515        );
3516        let refusal =
3517            read_send_params(&params(user(serde_json::json!([{"raw": "aGk="}])))).unwrap_err();
3518        let answer = wire(&refusal.into_response(Value::from(3)));
3519        assert_eq!(answer["error"]["code"], -32005);
3520        let info = &answer["error"]["data"][0];
3521        assert_eq!(info["reason"], "CONTENT_TYPE_NOT_SUPPORTED");
3522        assert_eq!(info["metadata"], serde_json::json!({"partIndex": "0"}));
3523        // Malformed beats unsupported.
3524        invalid_params(&params(user(
3525            serde_json::json!([{"raw": "aGk="}, {"text": 1}]),
3526        )));
3527    }
3528
3529    fn rendered(parts: Value) -> String {
3530        read_send_params(&params(user(parts))).unwrap().agent_text()
3531    }
3532
3533    #[test]
3534    fn agent_text_of_text_parts_is_the_text_joined_by_newlines() {
3535        assert_eq!(
3536            rendered(
3537                serde_json::json!([{"text": "one"}, {"text": "two", "mediaType": "text/markdown"}])
3538            ),
3539            "one\ntwo"
3540        );
3541    }
3542
3543    #[test]
3544    fn agent_text_fences_a_data_part_beyond_its_longest_backtick_run() {
3545        assert_eq!(
3546            rendered(serde_json::json!([{"text": "summarise"}, {"data": {"rows": [1, 2]}}])),
3547            "summarise\n```data\n{\n  \"rows\": [\n    1,\n    2\n  ]\n}\n```"
3548        );
3549        let three =
3550            rendered(serde_json::json!([{"data": "a ``` b", "mediaType": "application/json"}]));
3551        assert_eq!(
3552            three,
3553            "````data media-type=application/json\n\"a ``` b\"\n````"
3554        );
3555        let five = rendered(serde_json::json!([{"data": {"x": "`````"}}]));
3556        assert!(five.starts_with("``````data\n"), "{five}");
3557        assert!(five.ends_with("\n``````"), "{five}");
3558        assert_eq!(
3559            rendered(serde_json::json!([{"data": null}])),
3560            "```data\nnull\n```"
3561        );
3562    }
3563
3564    #[test]
3565    fn agent_text_sanitises_the_fence_label() {
3566        assert_eq!(
3567            rendered(serde_json::json!([{"data": 1, "mediaType": "a b=`c",
3568                "filename": "my\tfile.json"}])),
3569            "```data media-type=a_b__c filename=my_file.json\n1\n```"
3570        );
3571    }
3572
3573    #[test]
3574    fn a_reference_block_names_each_task_as_it_stands() {
3575        let mut r = TaskRegistry::new(8, TaskAcceptance::Queue);
3576        for id in ["tsk_a", "tsk_b", "tsk_c", "tsk_e"] {
3577            r.enqueue(id, "ctx_001");
3578        }
3579        r.start_task("tsk_a".to_string(), "ctx_001".to_string(), TaskLane::Bg);
3580        r.finish_task(TaskState::Completed);
3581        r.record_ending("tsk_a", "done", Some("four ```"), false, &[]);
3582        r.start_task("tsk_b".to_string(), "ctx_001".to_string(), TaskLane::Bg);
3583        r.finish_task(TaskState::Failed);
3584        r.record_ending("tsk_b", "the driver\nfailed", None, false, &[]);
3585        r.start_task("tsk_c".to_string(), "ctx_001".to_string(), TaskLane::Bg);
3586        r.record_owner("tsk_e", "member:reviewer");
3587        let ids: Vec<String> = ["tsk_a", "tsk_b", "tsk_c", "tsk_d", "tsk_e"]
3588            .map(str::to_string)
3589            .to_vec();
3590
3591        assert_eq!(
3592            r.reference_block(&ids, |_| true).unwrap(),
3593            "Referenced tasks:\n\
3594             - tsk_a: completed\n````response\nfour ```\n````\n\
3595             - tsk_b: failed: the driver failed\n\
3596             - tsk_c: working\n\
3597             - tsk_d: not a task this capsule holds\n\
3598             - tsk_e: submitted"
3599        );
3600        let member = |task_id: &str| r.owner(task_id) == Some("member:planner");
3601        let block = r.reference_block(&ids[4..], member).unwrap();
3602        assert_eq!(
3603            block,
3604            "Referenced tasks:\n- tsk_e: not a task this capsule holds"
3605        );
3606        assert_eq!(r.reference_block(&[], |_| true), None);
3607    }
3608
3609    #[test]
3610    fn user_message_is_a_v1_message_from_the_user() {
3611        assert_eq!(
3612            user_message("msg_1", Some("ctx_1"), "hello"),
3613            serde_json::json!({
3614                "messageId": "msg_1",
3615                "contextId": "ctx_1",
3616                "role": "ROLE_USER",
3617                "parts": [{"text": "hello", "mediaType": "text/plain"}],
3618            })
3619        );
3620        assert!(user_message("msg_1", None, "hello")
3621            .get("contextId")
3622            .is_none());
3623        assert!(read_send_params(&params(user_message("msg_1", None, "hello"))).is_ok());
3624    }
3625
3626    #[test]
3627    fn send_message_params_answer_at_acceptance() {
3628        let message = user_message("msg_1", None, "hello");
3629        let params = send_message_params(message.clone());
3630        assert_eq!(
3631            params,
3632            serde_json::json!({
3633                "message": message,
3634                "configuration": {"returnImmediately": true},
3635            })
3636        );
3637        assert!(read_send_params(&params).unwrap().return_immediately);
3638    }
3639
3640    #[test]
3641    fn send_message_result_is_exactly_one_member_of_the_oneof() {
3642        let task = serde_json::json!({"id": "tsk_1", "contextId": "c",
3643            "status": {"state": "TASK_STATE_SUBMITTED"}});
3644        assert!(matches!(
3645            SendMessageResult::from_result(&serde_json::json!({ "task": task })),
3646            Ok(SendMessageResult::Task(task)) if task.id == "tsk_1"
3647        ));
3648        assert!(matches!(
3649            SendMessageResult::from_result(&serde_json::json!({"message": {"messageId": "m"}})),
3650            Ok(SendMessageResult::Message(_))
3651        ));
3652        for refused in [
3653            serde_json::json!({}),
3654            serde_json::json!({"task": task, "message": {}}),
3655            task.clone(),
3656            serde_json::json!({"task": {"id": "tsk_1", "contextId": "c",
3657                "status": {"state": "submitted"}}}),
3658        ] {
3659            assert!(
3660                SendMessageResult::from_result(&refused).is_err(),
3661                "{refused}"
3662            );
3663        }
3664    }
3665}