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