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
19pub const A2A_VERSION_HEADER: &str = "A2A-Version";
23
24pub const A2A_PROTOCOL_VERSION: &str = "1.0";
27
28pub(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
46pub(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
68pub const A2A_EXTENSIONS_HEADER: &str = "A2A-Extensions";
73
74pub(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
88pub(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
97pub(crate) const PARSE_ERROR: i32 = -32700;
101pub(crate) const INVALID_REQUEST: i32 = -32600;
103pub(crate) const METHOD_NOT_FOUND: i32 = -32601;
105pub(crate) const INVALID_PARAMS: i32 = -32602;
107pub(crate) const INTERNAL_ERROR: i32 = -32603;
109
110#[derive(Debug, Clone)]
113pub(crate) struct JsonRpcRequest {
114 pub id: Value,
116 pub method: String,
117 pub params: Value,
120}
121
122impl JsonRpcRequest {
123 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 #[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 pub(crate) fn err(id: Value, code: i32, message: &str) -> Self {
188 Self::error(id, code, message, None)
189 }
190
191 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 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 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 #[serde(skip_serializing_if = "Option::is_none")]
255 pub data: Option<Vec<Value>>,
256}
257
258const ERROR_INFO_TYPE: &str = "type.googleapis.com/google.rpc.ErrorInfo";
260
261pub(crate) const A2A_ERROR_DOMAIN: &str = "a2a-protocol.org";
263
264pub(crate) const MURMUR_ERROR_DOMAIN: &str = "murmur.nexus";
266
267fn 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#[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 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 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 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 pub fn from_code(code: i32) -> Option<A2aError> {
346 Self::ALL.into_iter().find(|error| error.code() == code)
347 }
348}
349
350#[derive(Debug, Clone, Copy, PartialEq, Eq)]
356pub enum MurmurError {
357 CompletionMisaddressed,
359 CompletionNotAwaited,
362}
363
364impl MurmurError {
365 pub const ALL: [MurmurError; 2] = [
367 MurmurError::CompletionMisaddressed,
368 MurmurError::CompletionNotAwaited,
369 ];
370
371 pub fn code(self) -> i32 {
373 match self {
374 MurmurError::CompletionMisaddressed => -31001,
375 MurmurError::CompletionNotAwaited => -31002,
376 }
377 }
378
379 pub fn reason(self) -> &'static str {
381 match self {
382 MurmurError::CompletionMisaddressed => "COMPLETION_MISADDRESSED",
383 MurmurError::CompletionNotAwaited => "COMPLETION_NOT_AWAITED",
384 }
385 }
386}
387
388pub(crate) const TEXT_MEDIA_TYPE: &str = "text/plain";
392
393pub(crate) const DATA_MEDIA_TYPE: &str = "application/json";
395
396#[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 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#[derive(Debug, Clone, PartialEq)]
417pub(crate) enum PartContent {
418 Text(String),
419 Raw(String),
422 Url(String),
424 Data(Value),
426}
427
428impl PartContent {
429 pub(crate) fn kind(&self) -> &'static str {
431 match self {
432 Self::Text(_) => "text",
433 Self::Raw(_) => "raw",
434 Self::Url(_) => "url",
435 Self::Data(_) => "data",
436 }
437 }
438}
439
440const PART_CONTENT_FIELDS: [&str; 4] = ["text", "raw", "url", "data"];
442
443#[derive(Debug, Clone, PartialEq)]
449pub(crate) struct Part {
450 pub content: PartContent,
451 pub media_type: Option<String>,
452 pub filename: Option<String>,
453 pub metadata: Option<Value>,
454}
455
456impl Part {
457 pub(crate) fn is_image(&self) -> bool {
459 matches!(self.content, PartContent::Raw(_))
460 && self
461 .media_type
462 .as_deref()
463 .is_some_and(crate::images::is_allowed_media_type)
464 }
465
466 pub(crate) fn text(text: impl Into<String>) -> Self {
468 Self::of(PartContent::Text(text.into()), TEXT_MEDIA_TYPE)
469 }
470
471 pub(crate) fn data(value: Value) -> Self {
473 Self::of(PartContent::Data(value), DATA_MEDIA_TYPE)
474 }
475
476 fn of(content: PartContent, media_type: &str) -> Self {
477 Self {
478 content,
479 media_type: Some(media_type.to_string()),
480 filename: None,
481 metadata: None,
482 }
483 }
484
485 pub(crate) fn as_text(&self) -> Option<&str> {
487 match &self.content {
488 PartContent::Text(text) => Some(text),
489 _ => None,
490 }
491 }
492
493 #[cfg(test)]
495 pub(crate) fn as_data(&self) -> Option<&Value> {
496 match &self.content {
497 PartContent::Data(value) => Some(value),
498 _ => None,
499 }
500 }
501
502 pub(crate) fn to_json(&self) -> Value {
504 let mut object = serde_json::Map::new();
505 let (field, value) = match &self.content {
506 PartContent::Text(text) => ("text", Value::String(text.clone())),
507 PartContent::Raw(raw) => ("raw", Value::String(raw.clone())),
508 PartContent::Url(url) => ("url", Value::String(url.clone())),
509 PartContent::Data(value) => ("data", value.clone()),
510 };
511 object.insert(field.to_string(), value);
512 if let Some(media_type) = &self.media_type {
513 object.insert("mediaType".to_string(), Value::String(media_type.clone()));
514 }
515 if let Some(filename) = &self.filename {
516 object.insert("filename".to_string(), Value::String(filename.clone()));
517 }
518 if let Some(metadata) = &self.metadata {
519 object.insert("metadata".to_string(), metadata.clone());
520 }
521 Value::Object(object)
522 }
523
524 fn content_count_error(value: &Value) -> Option<String> {
527 let Some(object) = value.as_object() else {
528 return Some("is not an object".to_string());
529 };
530 let set: Vec<&str> = PART_CONTENT_FIELDS
531 .into_iter()
532 .filter(|field| object.contains_key(*field))
533 .collect();
534 match set.as_slice() {
535 [_] => None,
536 [] => Some("sets none of text, raw, url and data".to_string()),
537 more => Some(format!(
538 "sets {}; a part sets exactly one of text, raw, url and data",
539 more.join(" and ")
540 )),
541 }
542 }
543
544 pub(crate) fn from_json(value: &Value) -> Result<Part, String> {
548 if let Some(error) = Self::content_count_error(value) {
549 return Err(error);
550 }
551 let object = value.as_object().expect("checked to be an object");
552 let string = |field: &str| -> Result<Option<String>, String> {
553 match object.get(field) {
554 None => Ok(None),
555 Some(Value::String(text)) => Ok(Some(text.clone())),
556 Some(_) => Err(format!("{field} is not a string")),
557 }
558 };
559 let content = if let Some(text) = string("text")? {
560 PartContent::Text(text)
561 } else if let Some(raw) = string("raw")? {
562 PartContent::Raw(raw)
563 } else if let Some(url) = string("url")? {
564 PartContent::Url(url)
565 } else {
566 PartContent::Data(object.get("data").cloned().unwrap_or(Value::Null))
567 };
568 Ok(Part {
569 content,
570 media_type: string("mediaType")?,
571 filename: string("filename")?,
572 metadata: object.get("metadata").cloned(),
573 })
574 }
575}
576
577impl Serialize for Part {
578 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
579 self.to_json().serialize(serializer)
580 }
581}
582
583impl<'de> Deserialize<'de> for Part {
584 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
585 let value = Value::deserialize(deserializer)?;
586 Part::from_json(&value).map_err(|error| serde::de::Error::custom(format!("part {error}")))
587 }
588}
589
590#[derive(Debug, Clone, Serialize, Deserialize)]
591#[serde(rename_all = "camelCase")]
592pub(crate) struct A2aTask {
593 pub id: String,
594 pub context_id: String,
595 pub status: TaskStatus,
596 #[serde(skip_serializing_if = "Option::is_none")]
597 pub artifacts: Option<Vec<A2aArtifact>>,
598 #[serde(default, skip_serializing_if = "Option::is_none")]
603 pub metadata: Option<serde_json::Value>,
604}
605
606impl A2aTask {
607 pub(crate) fn artifact(&self, artifact_id: &str) -> Option<&A2aArtifact> {
609 self.artifacts
610 .as_deref()?
611 .iter()
612 .find(|artifact| artifact.artifact_id == artifact_id)
613 }
614
615 pub(crate) fn status_text(&self) -> Option<&str> {
617 self.status.message.as_ref()?.text()
618 }
619}
620
621#[derive(Debug, Clone, Serialize, Deserialize)]
624#[serde(rename_all = "camelCase")]
625pub(crate) struct A2aArtifact {
626 pub artifact_id: String,
627 #[serde(default)]
628 pub name: String,
629 pub parts: Vec<Part>,
630}
631
632impl A2aArtifact {
633 pub(crate) fn named(name: &str, parts: Vec<Part>) -> Self {
635 Self {
636 artifact_id: name.to_string(),
637 name: name.to_string(),
638 parts,
639 }
640 }
641
642 pub(crate) fn first_text(&self) -> Option<&str> {
644 self.parts.first()?.as_text()
645 }
646}
647
648#[derive(Debug, Clone, Serialize, Deserialize)]
649pub(crate) struct TaskStatus {
650 pub state: TaskState,
651 #[serde(default, skip_serializing_if = "Option::is_none")]
653 pub message: Option<StatusMessage>,
654 #[serde(default, skip_serializing_if = "Option::is_none")]
657 pub timestamp: Option<String>,
658}
659
660impl TaskStatus {
661 pub(crate) fn of(state: TaskState) -> Self {
663 Self::at(state, None, status_now())
664 }
665
666 pub(crate) fn at(
668 state: TaskState,
669 message: Option<StatusMessage>,
670 when: DateTime<Utc>,
671 ) -> Self {
672 Self {
673 state,
674 message,
675 timestamp: Some(status_timestamp(when)),
676 }
677 }
678}
679
680pub(crate) fn status_now() -> DateTime<Utc> {
683 let now = Utc::now();
684 DateTime::from_timestamp_millis(now.timestamp_millis()).unwrap_or(now)
685}
686
687#[cfg(test)]
690pub(crate) fn assert_status_timestamp(status: &Value) -> DateTime<Utc> {
691 let timestamp = status["timestamp"]
692 .as_str()
693 .unwrap_or_else(|| panic!("no timestamp: {status}"));
694 let parsed = DateTime::parse_from_rfc3339(timestamp)
695 .unwrap_or_else(|error| panic!("{timestamp} is not RFC 3339: {error}"))
696 .with_timezone(&Utc);
697 assert_eq!(status_timestamp(parsed), timestamp, "{status}");
698 parsed
699}
700
701#[cfg(test)]
704pub(crate) fn assert_bare_status(status: &Value, state: &str) {
705 assert_status_timestamp(status);
706 assert_eq!(
707 *status,
708 serde_json::json!({"state": state, "timestamp": status["timestamp"]})
709 );
710}
711
712pub(crate) fn status_timestamp(at: DateTime<Utc>) -> String {
715 at.to_rfc3339_opts(SecondsFormat::Millis, true)
716}
717
718#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
720#[serde(rename_all = "camelCase")]
721pub(crate) struct StatusMessage {
722 pub message_id: String,
723 pub context_id: String,
724 pub task_id: String,
725 pub role: Role,
726 pub parts: Vec<Part>,
727}
728
729impl StatusMessage {
730 pub(crate) fn agent(task_id: &str, context_id: &str, text: &str) -> Self {
732 Self {
733 message_id: format!("msg_{task_id}_status"),
734 context_id: context_id.to_string(),
735 task_id: task_id.to_string(),
736 role: Role::Agent,
737 parts: vec![Part::text(text)],
738 }
739 }
740
741 pub(crate) fn text(&self) -> Option<&str> {
743 self.parts.first()?.as_text()
744 }
745}
746
747#[derive(Debug, Clone, PartialEq, Eq)]
749struct TaskEnding {
750 message: String,
752 response: Option<String>,
754 no_answer: bool,
756 no_answer_below: Vec<(String, String)>,
758 running_commands: Vec<RunningCommand>,
760}
761
762#[derive(Debug, Clone, PartialEq, Eq)]
765pub(crate) struct RunningCommand {
766 pub work_id: String,
767 pub binary: String,
768 pub command: String,
770 pub running_ms: u64,
772}
773
774const RUNNING_COMMAND_TEXT_CHARS: usize = 200;
776
777impl TaskEnding {
778 fn metadata(&self) -> Option<serde_json::Value> {
780 let mut murmur = serde_json::Map::new();
781 if self.no_answer {
782 murmur.insert("noAnswer".to_string(), serde_json::Value::Bool(true));
783 }
784 if !self.no_answer_below.is_empty() {
785 let below = self
786 .no_answer_below
787 .iter()
788 .map(|(member, status)| serde_json::json!({"member": member, "status": status}))
789 .collect();
790 murmur.insert("noAnswerBelow".to_string(), serde_json::Value::Array(below));
791 }
792 if !self.running_commands.is_empty() {
793 let running = self
794 .running_commands
795 .iter()
796 .map(|running| {
797 let mut command: String = running
798 .command
799 .chars()
800 .take(RUNNING_COMMAND_TEXT_CHARS)
801 .collect();
802 if running
803 .command
804 .chars()
805 .nth(RUNNING_COMMAND_TEXT_CHARS)
806 .is_some()
807 {
808 command.push('…');
809 }
810 serde_json::json!({
811 "workId": running.work_id,
812 "binary": running.binary,
813 "command": command,
814 "runningMs": running.running_ms,
815 })
816 })
817 .collect();
818 murmur.insert(
819 "runningCommands".to_string(),
820 serde_json::Value::Array(running),
821 );
822 }
823 (!murmur.is_empty()).then(|| serde_json::json!({ "murmur": murmur }))
824 }
825}
826
827pub(crate) const RESPONSE_ARTIFACT: &str = "response";
830
831pub(crate) const PROMPT_ARTIFACT: &str = "prompt";
833
834#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
845pub(crate) enum TaskState {
846 #[serde(rename = "TASK_STATE_SUBMITTED")]
847 Submitted,
848 #[serde(rename = "TASK_STATE_WORKING")]
849 Working,
850 #[serde(rename = "TASK_STATE_INPUT_REQUIRED")]
851 InputRequired,
852 #[serde(rename = "TASK_STATE_COMPLETED")]
853 Completed,
854 #[serde(rename = "TASK_STATE_FAILED")]
855 Failed,
856 #[serde(rename = "TASK_STATE_REJECTED")]
857 Rejected,
858 #[serde(rename = "TASK_STATE_CANCELED")]
861 Canceled,
862}
863
864impl TaskState {
865 pub(crate) const ALL: [TaskState; 7] = [
867 TaskState::Submitted,
868 TaskState::Working,
869 TaskState::Completed,
870 TaskState::Failed,
871 TaskState::Canceled,
872 TaskState::InputRequired,
873 TaskState::Rejected,
874 ];
875
876 pub(crate) fn wire_name(&self) -> &'static str {
878 match self {
879 Self::Submitted => "TASK_STATE_SUBMITTED",
880 Self::Working => "TASK_STATE_WORKING",
881 Self::InputRequired => "TASK_STATE_INPUT_REQUIRED",
882 Self::Completed => "TASK_STATE_COMPLETED",
883 Self::Failed => "TASK_STATE_FAILED",
884 Self::Rejected => "TASK_STATE_REJECTED",
885 Self::Canceled => "TASK_STATE_CANCELED",
886 }
887 }
888
889 pub(crate) fn from_wire_name(wire: &str) -> Option<TaskState> {
892 Self::ALL
893 .into_iter()
894 .find(|state| state.wire_name() == wire)
895 }
896
897 pub(crate) fn as_str(&self) -> &'static str {
902 match self {
903 Self::Submitted => "submitted",
904 Self::Working => "working",
905 Self::InputRequired => "input-required",
906 Self::Completed => "completed",
907 Self::Failed => "failed",
908 Self::Rejected => "rejected",
909 Self::Canceled => "canceled",
910 }
911 }
912
913 pub(crate) fn is_terminal(&self) -> bool {
918 match self {
919 Self::Submitted | Self::Working | Self::InputRequired => false,
920 Self::Completed | Self::Failed | Self::Rejected | Self::Canceled => true,
921 }
922 }
923}
924
925pub fn murmur_state_name(wire: &str) -> Option<&'static str> {
932 Some(match wire {
933 "TASK_STATE_SUBMITTED" => "submitted",
934 "TASK_STATE_WORKING" => "working",
935 "TASK_STATE_COMPLETED" => "completed",
936 "TASK_STATE_FAILED" => "failed",
937 "TASK_STATE_CANCELED" => "canceled",
938 "TASK_STATE_INPUT_REQUIRED" => "input-required",
939 "TASK_STATE_REJECTED" => "rejected",
940 "TASK_STATE_AUTH_REQUIRED" => "auth-required",
941 _ => return None,
942 })
943}
944
945pub(crate) const MAX_REFERENCE_TASK_IDS: usize = 16;
949
950#[derive(Debug, Clone, PartialEq)]
953pub(crate) struct IncomingMessage {
954 pub message_id: String,
955 pub context_id: Option<String>,
956 pub task_id: Option<String>,
958 pub reference_task_ids: Vec<String>,
960 pub parts: Vec<Part>,
963 pub door_extension: bool,
967 pub forget_session: bool,
971 pub return_immediately: bool,
974}
975
976#[derive(Debug, Clone, PartialEq, Eq)]
978pub(crate) enum MessageRefusal {
979 InvalidParams(String),
981 ContentTypeNotSupported {
984 part_index: usize,
985 kind: &'static str,
986 media_type: Option<String>,
987 },
988 PushNotificationNotSupported,
991}
992
993impl MessageRefusal {
994 pub(crate) fn into_response(self, id: Value) -> JsonRpcResponse {
996 match self {
997 Self::InvalidParams(message) => {
998 JsonRpcResponse::err(id, INVALID_PARAMS, &format!("Invalid params: {message}"))
999 }
1000 Self::ContentTypeNotSupported {
1001 part_index,
1002 kind,
1003 media_type,
1004 } => {
1005 let mut metadata = vec![("partIndex", part_index.to_string())];
1006 if let Some(media_type) = &media_type {
1007 metadata.push(("mediaType", media_type.clone()));
1008 }
1009 JsonRpcResponse::a2a_error(
1010 id,
1011 A2aError::ContentTypeNotSupported,
1012 &format!(
1013 "message.parts[{part_index}] is a {kind} part; this agent reads text parts, \
1014 data parts and image raw parts only"
1015 ),
1016 &metadata,
1017 )
1018 }
1019 Self::PushNotificationNotSupported => push_notification_not_supported(id),
1020 }
1021 }
1022}
1023
1024pub(crate) fn push_notification_not_supported(id: Value) -> JsonRpcResponse {
1027 JsonRpcResponse::a2a_error(
1028 id,
1029 A2aError::PushNotificationNotSupported,
1030 "push notifications are not supported by this agent",
1031 &[],
1032 )
1033}
1034
1035pub(crate) fn read_send_params(params: &Value) -> Result<IncomingMessage, MessageRefusal> {
1053 let invalid = |message: String| Err(MessageRefusal::InvalidParams(message));
1054 let Some(message) = params.get("message").and_then(Value::as_object) else {
1055 return invalid("params.message must be an A2A Message object".to_string());
1056 };
1057 let message_id = match message.get("messageId") {
1058 Some(Value::String(id)) if !id.is_empty() => id.clone(),
1059 _ => return invalid("message.messageId must be a non-empty string".to_string()),
1060 };
1061 if message.get("role").and_then(Value::as_str) != Some(Role::User.wire_name()) {
1062 return invalid(format!(
1063 "message.role must be {}: a message to this agent is from the user",
1064 Role::User.wire_name()
1065 ));
1066 }
1067 let raw_parts = match message.get("parts") {
1068 Some(Value::Array(parts)) if !parts.is_empty() => parts,
1069 _ => return invalid("message.parts must be a non-empty array".to_string()),
1070 };
1071 for (index, part) in raw_parts.iter().enumerate() {
1072 if let Some(error) = Part::content_count_error(part) {
1073 return invalid(format!("message.parts[{index}] {error}"));
1074 }
1075 }
1076 let mut parts = Vec::with_capacity(raw_parts.len());
1077 for (index, part) in raw_parts.iter().enumerate() {
1078 match Part::from_json(part) {
1079 Ok(part) => parts.push(part),
1080 Err(error) => return invalid(format!("message.parts[{index}].{error}")),
1081 }
1082 }
1083 let optional_string = |field: &str| -> Result<Option<String>, MessageRefusal> {
1084 match message.get(field) {
1085 None => Ok(None),
1086 Some(Value::String(value)) => Ok(Some(value.clone())),
1087 Some(_) => Err(MessageRefusal::InvalidParams(format!(
1088 "message.{field} must be a string"
1089 ))),
1090 }
1091 };
1092 let task_id = optional_string("taskId")?.filter(|id| !id.is_empty());
1093 let context_id = optional_string("contextId")?;
1094 let reference_task_ids = match message.get("referenceTaskIds") {
1095 None => Vec::new(),
1096 Some(Value::Array(ids)) => {
1097 let mut distinct: Vec<String> = Vec::new();
1098 for id in ids {
1099 match id {
1100 Value::String(id) if !id.is_empty() => {
1101 if !distinct.contains(id) {
1102 distinct.push(id.clone());
1103 }
1104 }
1105 _ => {
1106 return invalid(
1107 "message.referenceTaskIds must hold non-empty strings".to_string(),
1108 )
1109 }
1110 }
1111 }
1112 if distinct.len() > MAX_REFERENCE_TASK_IDS {
1113 return invalid(format!(
1114 "message.referenceTaskIds names {} tasks; at most {MAX_REFERENCE_TASK_IDS} \
1115 may be referenced",
1116 distinct.len()
1117 ));
1118 }
1119 distinct
1120 }
1121 Some(_) => return invalid("message.referenceTaskIds must be an array".to_string()),
1122 };
1123 let door_metadata = message
1124 .get("metadata")
1125 .and_then(Value::as_object)
1126 .and_then(|metadata| metadata.get(DOOR_EXTENSION_URI));
1127 let forget_session = match door_metadata {
1128 None => false,
1129 Some(Value::Object(door)) => match door.get("forgetSession") {
1130 None => false,
1131 Some(Value::Bool(forget)) => *forget,
1132 Some(_) => {
1133 return invalid(format!(
1134 "message.metadata[\"{DOOR_EXTENSION_URI}\"].forgetSession must be a boolean"
1135 ))
1136 }
1137 },
1138 Some(_) => {
1139 return invalid(format!(
1140 "message.metadata[\"{DOOR_EXTENSION_URI}\"] must be an object"
1141 ))
1142 }
1143 };
1144 if let (true, Some(task_id)) = (forget_session, &task_id) {
1145 return invalid(format!(
1146 "forgetSession starts a new conversation, so it goes on a message that starts a task, \
1147 not on one that continues task {task_id}"
1148 ));
1149 }
1150 let configuration = params.get("configuration");
1151 let return_immediately = match configuration.and_then(|c| c.get("returnImmediately")) {
1152 None | Some(Value::Null) => false,
1153 Some(Value::Bool(immediately)) => *immediately,
1154 Some(_) => return invalid("configuration.returnImmediately must be a boolean".to_string()),
1155 };
1156 if let Some((part_index, part)) = parts.iter().enumerate().find(|(_, part)| {
1157 matches!(part.content, PartContent::Url(_))
1158 || (matches!(part.content, PartContent::Raw(_)) && !part.is_image())
1159 }) {
1160 return Err(MessageRefusal::ContentTypeNotSupported {
1161 part_index,
1162 kind: part.content.kind(),
1163 media_type: part.media_type.clone(),
1164 });
1165 }
1166 if configuration
1167 .and_then(|configuration| configuration.get("taskPushNotificationConfig"))
1168 .is_some_and(|config| !config.is_null())
1169 {
1170 return Err(MessageRefusal::PushNotificationNotSupported);
1171 }
1172 Ok(IncomingMessage {
1173 message_id,
1174 context_id,
1175 task_id,
1176 reference_task_ids,
1177 parts,
1178 door_extension: door_metadata.is_some(),
1179 forget_session,
1180 return_immediately,
1181 })
1182}
1183
1184impl IncomingMessage {
1185 pub(crate) fn agent_text(&self) -> String {
1189 self.parts
1190 .iter()
1191 .filter_map(|part| {
1192 Some(match &part.content {
1193 PartContent::Text(text) => text.clone(),
1194 PartContent::Data(value) => {
1195 let mut label = "data".to_string();
1196 if let Some(media_type) = &part.media_type {
1197 label.push_str(&format!(
1198 " media-type={}",
1199 fence_label_value(media_type)
1200 ));
1201 }
1202 if let Some(filename) = &part.filename {
1203 label.push_str(&format!(" filename={}", fence_label_value(filename)));
1204 }
1205 let body = serde_json::to_string_pretty(value).unwrap_or_default();
1206 code_fence(&label, &body)
1207 }
1208 PartContent::Raw(_) => return None,
1211 PartContent::Url(_) => unreachable!("read_send_params refuses every url part"),
1212 })
1213 })
1214 .collect::<Vec<_>>()
1215 .join("\n")
1216 }
1217
1218 pub(crate) fn part_kinds(&self) -> Vec<&'static str> {
1220 self.parts.iter().map(|part| part.content.kind()).collect()
1221 }
1222}
1223
1224#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1226pub(crate) enum ImagePolicy {
1227 Accept(murmur_artifact::ContextImagesConfig),
1229 ProcessTransport,
1232}
1233
1234impl ImagePolicy {
1235 pub(crate) fn for_transport(
1237 transport: &str,
1238 limits: murmur_artifact::ContextImagesConfig,
1239 ) -> Self {
1240 if transport == "process" {
1241 Self::ProcessTransport
1242 } else {
1243 Self::Accept(limits)
1244 }
1245 }
1246}
1247
1248impl Default for ImagePolicy {
1249 fn default() -> Self {
1250 Self::Accept(murmur_artifact::ContextImagesConfig::default())
1251 }
1252}
1253
1254pub(crate) fn message_images(
1262 message: &IncomingMessage,
1263 policy: ImagePolicy,
1264) -> Result<Vec<(usize, crate::images::CheckedImage)>, MessageRefusal> {
1265 let mut images = Vec::new();
1266 for (index, part) in message.parts.iter().enumerate() {
1267 let PartContent::Raw(data) = &part.content else {
1268 continue;
1269 };
1270 let media_type = part.media_type.as_deref().unwrap_or_default();
1271 let limits = match policy {
1272 ImagePolicy::ProcessTransport => {
1273 return Err(MessageRefusal::InvalidParams(format!(
1274 "message.parts[{index}] is an image, and this capsule runs on \
1275 inference.transport: process, whose harness takes no images from the door"
1276 )))
1277 }
1278 ImagePolicy::Accept(limits) => limits,
1279 };
1280 if let Some(task_id) = &message.task_id {
1281 return Err(MessageRefusal::InvalidParams(format!(
1282 "message.parts[{index}] is an image; a message continuing task {task_id} carries \
1283 text only, so send images on the message that starts a task"
1284 )));
1285 }
1286 if images.len() as u32 >= limits.max_per_turn {
1287 return Err(MessageRefusal::InvalidParams(format!(
1288 "message.parts[{index}] is image {} of this message; context.images.max_per_turn \
1289 is {}",
1290 images.len() + 1,
1291 limits.max_per_turn
1292 )));
1293 }
1294 match crate::images::decode_raw_part(media_type, data, limits.max_bytes) {
1295 Ok(image) => images.push((index, image)),
1296 Err(refusal) => {
1297 return Err(MessageRefusal::InvalidParams(format!(
1298 "message.parts[{index}] was not attached: {} ({}); context.images.max_bytes \
1299 is {} bytes",
1300 refusal.reason(),
1301 refusal.code(),
1302 limits.max_bytes
1303 )))
1304 }
1305 }
1306 }
1307 Ok(images)
1308}
1309
1310pub(crate) fn code_fence(label: &str, body: &str) -> String {
1314 let mut longest = 0;
1315 let mut run = 0;
1316 for c in body.chars() {
1317 run = if c == '`' { run + 1 } else { 0 };
1318 longest = longest.max(run);
1319 }
1320 let fence = "`".repeat((longest + 1).max(3));
1321 format!("{fence}{label}\n{body}\n{fence}")
1322}
1323
1324fn fence_label_value(value: &str) -> String {
1326 value
1327 .chars()
1328 .map(|c| {
1329 if c.is_whitespace() || c == '`' || c == '=' {
1330 '_'
1331 } else {
1332 c
1333 }
1334 })
1335 .collect()
1336}
1337
1338pub(crate) fn user_message(message_id: &str, context_id: Option<&str>, text: &str) -> Value {
1343 let mut message = serde_json::json!({
1344 "messageId": message_id,
1345 "role": Role::User.wire_name(),
1346 "parts": [Part::text(text).to_json()],
1347 });
1348 if let Some(context_id) = context_id {
1349 message["contextId"] = Value::String(context_id.to_string());
1350 }
1351 message
1352}
1353
1354pub(crate) fn send_message_params(message: Value) -> Value {
1358 serde_json::json!({
1359 "message": message,
1360 "configuration": { "returnImmediately": true },
1361 })
1362}
1363
1364#[derive(Debug, Clone)]
1367pub(crate) enum SendMessageResult {
1368 Task(Box<A2aTask>),
1369 Message(Value),
1371}
1372
1373impl SendMessageResult {
1374 pub(crate) fn from_result(result: &Value) -> Result<SendMessageResult, String> {
1377 match send_message_member(result)? {
1378 ("task", task) => serde_json::from_value(task.clone())
1379 .map(|task| SendMessageResult::Task(Box::new(task)))
1380 .map_err(|error| format!("its task is not an A2A task: {error}")),
1381 (_, message) => Ok(SendMessageResult::Message(message.clone())),
1382 }
1383 }
1384}
1385
1386pub(crate) fn send_message_member(result: &Value) -> Result<(&'static str, &Value), String> {
1389 match (result.get("task"), result.get("message")) {
1390 (Some(task), None) => Ok(("task", task)),
1391 (None, Some(message)) => Ok(("message", message)),
1392 (Some(_), Some(_)) => Err("it answered both a task and a message".to_string()),
1393 (None, None) => Err(format!(
1394 "it answered neither a task nor a message: {result}"
1395 )),
1396 }
1397}
1398
1399#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1405pub(crate) enum CancelOutcome {
1406 Accepted,
1408 AlreadyTerminal,
1410 Unknown,
1412}
1413
1414#[derive(Debug, Clone)]
1417pub(crate) enum TaskSlotState {
1418 Empty,
1419 Running {
1420 task_id: String,
1421 context_id: String,
1422 lane: TaskLane,
1425 },
1426 Done,
1428}
1429
1430pub(crate) struct TaskRegistry {
1435 pub(crate) active_slot: TaskSlotState,
1436 pub(crate) history: HashMap<String, (TaskState, String)>,
1438 pub(crate) pending_count: usize,
1439 pub(crate) queue_depth: usize,
1440 pub(crate) task_acceptance: TaskAcceptance,
1441 input_waiters: HashMap<String, (String, oneshot::Sender<String>)>,
1443 input_timeouts: HashSet<String>,
1445 cancels: HashMap<String, CancelSignal>,
1451 resource_generation: Arc<AtomicU64>,
1455 closed: bool,
1458 endings: HashMap<String, TaskEnding>,
1460 owners: HashMap<String, String>,
1463 revision: u64,
1465 revisions: HashMap<String, u64>,
1467 status_times: HashMap<String, DateTime<Utc>>,
1470 standing_answers: HashSet<String>,
1473 stopped_at_deadline: HashSet<String>,
1476 canceled_at_deadline: HashSet<String>,
1479 image_policy: ImagePolicy,
1481 attachments: HashMap<String, HashMap<String, IndexedAttachment>>,
1484}
1485
1486#[derive(Debug, Clone, PartialEq, Eq)]
1488pub(crate) struct IndexedAttachment {
1489 pub(crate) media_type: String,
1490 pub(crate) blob: std::path::PathBuf,
1491}
1492
1493pub(crate) const REJECTED_SESSION_ENDED_MESSAGE: &str =
1495 "task rejected: the session ended before this task started";
1496
1497pub(crate) const REJECTED_SESSION_STOPPED_MESSAGE: &str =
1499 "task rejected: the session was stopped before this task started";
1500
1501pub(crate) const REJECTED_SESSION_CLOSING_MESSAGE: &str = "task rejected: the session is closing";
1503
1504pub(crate) const REJECTED_BUSY_MESSAGE: &str = "task rejected: capsule is busy";
1507
1508impl TaskRegistry {
1509 pub(crate) fn new(queue_depth: usize, task_acceptance: TaskAcceptance) -> Self {
1510 Self {
1511 active_slot: TaskSlotState::Empty,
1512 history: HashMap::new(),
1513 pending_count: 0,
1514 queue_depth,
1515 task_acceptance,
1516 input_waiters: HashMap::new(),
1517 input_timeouts: HashSet::new(),
1518 cancels: HashMap::new(),
1519 resource_generation: Arc::new(AtomicU64::new(0)),
1520 closed: false,
1521 endings: HashMap::new(),
1522 owners: HashMap::new(),
1523 revision: 0,
1524 revisions: HashMap::new(),
1525 status_times: HashMap::new(),
1526 standing_answers: HashSet::new(),
1527 stopped_at_deadline: HashSet::new(),
1528 canceled_at_deadline: HashSet::new(),
1529 image_policy: ImagePolicy::default(),
1530 attachments: HashMap::new(),
1531 }
1532 }
1533
1534 pub(crate) fn with_image_policy(mut self, policy: ImagePolicy) -> Self {
1536 self.image_policy = policy;
1537 self
1538 }
1539
1540 pub(crate) fn image_policy(&self) -> ImagePolicy {
1541 self.image_policy
1542 }
1543
1544 pub(crate) fn index_attachment(
1546 &mut self,
1547 task_id: &str,
1548 sha256: &str,
1549 media_type: &str,
1550 blob: std::path::PathBuf,
1551 ) {
1552 self.attachments
1553 .entry(task_id.to_string())
1554 .or_default()
1555 .insert(
1556 sha256.to_string(),
1557 IndexedAttachment {
1558 media_type: media_type.to_string(),
1559 blob,
1560 },
1561 );
1562 }
1563
1564 pub(crate) fn attachment(&self, task_id: &str, sha256: &str) -> Option<&IndexedAttachment> {
1567 self.attachments.get(task_id)?.get(sha256)
1568 }
1569
1570 pub(crate) fn resource_generation(&self) -> Arc<AtomicU64> {
1576 Arc::clone(&self.resource_generation)
1577 }
1578
1579 pub(crate) fn advance_resource_generation(&self) {
1586 self.resource_generation.fetch_add(1, Ordering::SeqCst);
1587 }
1588
1589 pub(crate) fn can_accept(&self) -> bool {
1590 if self.closed {
1591 return false;
1592 }
1593 match self.task_acceptance {
1594 TaskAcceptance::None => false,
1595 TaskAcceptance::Single => {
1596 matches!(self.active_slot, TaskSlotState::Empty) && self.pending_count == 0
1597 }
1598 TaskAcceptance::Queue => self.pending_count < self.queue_depth,
1599 }
1600 }
1601
1602 pub(crate) fn enqueue(&mut self, task_id: &str, context_id: &str) {
1603 self.pending_count += 1;
1604 self.record_state(task_id, TaskState::Submitted, context_id.to_string());
1605 }
1606
1607 pub(crate) fn withdraw(&mut self, task_id: &str) {
1610 self.pending_count = self.pending_count.saturating_sub(1);
1611 self.history.remove(task_id);
1612 self.revisions.remove(task_id);
1613 self.status_times.remove(task_id);
1614 self.owners.remove(task_id);
1615 }
1616
1617 pub(crate) fn record_state(&mut self, task_id: &str, state: TaskState, context_id: String) {
1621 self.history
1622 .insert(task_id.to_string(), (state, context_id));
1623 self.touch(task_id);
1624 }
1625
1626 fn touch(&mut self, task_id: &str) {
1629 self.revision += 1;
1630 self.revisions.insert(task_id.to_string(), self.revision);
1631 self.status_times.insert(task_id.to_string(), status_now());
1632 }
1633
1634 fn status_time(&self, task_id: &str) -> Option<DateTime<Utc>> {
1636 self.status_times.get(task_id).copied()
1637 }
1638
1639 pub(crate) fn record_owner(&mut self, task_id: &str, credential: &str) {
1642 self.owners
1643 .insert(task_id.to_string(), credential.to_string());
1644 }
1645
1646 pub(crate) fn owner(&self, task_id: &str) -> Option<&str> {
1649 self.owners.get(task_id).map(String::as_str)
1650 }
1651
1652 pub(crate) fn record_ending(
1658 &mut self,
1659 task_id: &str,
1660 message: &str,
1661 response: Option<&str>,
1662 no_answer: bool,
1663 no_answer_below: &[(String, String)],
1664 running_commands: &[RunningCommand],
1665 ) {
1666 let completed = matches!(self.history.get(task_id), Some((TaskState::Completed, _)));
1667 self.endings.insert(
1668 task_id.to_string(),
1669 TaskEnding {
1670 message: message.to_string(),
1671 response: response.filter(|_| completed).map(str::to_string),
1672 no_answer,
1673 no_answer_below: no_answer_below.to_vec(),
1674 running_commands: running_commands.to_vec(),
1675 },
1676 );
1677 self.touch(task_id);
1678 }
1679
1680 pub(crate) fn start_task(&mut self, task_id: String, context_id: String, lane: TaskLane) {
1683 debug_assert!(self.pending_count > 0);
1684 self.pending_count -= 1;
1685 self.record_state(&task_id, TaskState::Working, context_id.clone());
1686 self.active_slot = TaskSlotState::Running {
1687 task_id,
1688 context_id,
1689 lane,
1690 };
1691 }
1692
1693 pub(crate) fn active_lane(&self) -> Option<TaskLane> {
1698 match self.active_slot {
1699 TaskSlotState::Running { lane, .. } => Some(lane),
1700 TaskSlotState::Empty | TaskSlotState::Done => None,
1701 }
1702 }
1703
1704 pub(crate) fn finish_task(&mut self, final_state: TaskState) -> Option<TaskState> {
1708 let TaskSlotState::Running {
1709 ref task_id,
1710 ref context_id,
1711 ..
1712 } = self.active_slot
1713 else {
1714 return None;
1715 };
1716 let (tid, cid) = (task_id.clone(), context_id.clone());
1717 self.input_waiters.remove(&tid);
1718 self.input_timeouts.remove(&tid);
1719 self.cancels.remove(&tid);
1720 self.standing_answers.remove(&tid);
1721 self.stopped_at_deadline.remove(&tid);
1722 self.canceled_at_deadline.remove(&tid);
1723 let final_state = match self.history.get(&tid) {
1727 Some((TaskState::Canceled, _)) => TaskState::Canceled,
1728 _ => final_state,
1729 };
1730 self.record_state(&tid, final_state.clone(), cid);
1731 self.active_slot = TaskSlotState::Done;
1732 Some(final_state)
1733 }
1734
1735 pub(crate) fn record_input_timeout(&mut self, task_id: &str) {
1740 self.input_waiters.remove(task_id);
1741 if let Some((TaskState::InputRequired, ctx)) = self.history.get(task_id).cloned() {
1742 self.record_state(task_id, TaskState::Working, ctx);
1743 }
1744 self.input_timeouts.insert(task_id.to_string());
1745 }
1746
1747 pub(crate) fn take_input_timeout(&mut self, task_id: &str) -> bool {
1751 self.input_timeouts.remove(task_id)
1752 }
1753
1754 pub(crate) fn set_input_required(
1757 &mut self,
1758 task_id: &str,
1759 prompt: String,
1760 tx: oneshot::Sender<String>,
1761 ) -> Result<(), &'static str> {
1762 match &self.active_slot {
1763 TaskSlotState::Running {
1764 task_id: active_id, ..
1765 } if active_id == task_id => {}
1766 _ => return Err("task is not the active running task"),
1767 }
1768 let state = self.history.get(task_id).map(|(s, _)| s);
1769 if !matches!(state, Some(TaskState::Working)) {
1770 return Err("task is not in working state");
1771 }
1772 if let Some((_, ctx)) = self.history.get(task_id).cloned() {
1773 self.record_state(task_id, TaskState::InputRequired, ctx);
1774 }
1775 self.input_waiters.insert(task_id.to_string(), (prompt, tx));
1776 Ok(())
1777 }
1778
1779 pub(crate) fn deliver_input(
1781 &mut self,
1782 task_id: &str,
1783 text: String,
1784 ) -> Result<(), &'static str> {
1785 let state = self.history.get(task_id).map(|(s, _)| s);
1786 if !matches!(state, Some(TaskState::InputRequired)) {
1787 return Err("task is not in input-required state");
1788 }
1789 let Some((_, tx)) = self.input_waiters.remove(task_id) else {
1790 return Err("no input waiter found for task");
1791 };
1792 if let Some((_, ctx)) = self.history.get(task_id).cloned() {
1793 self.record_state(task_id, TaskState::Working, ctx);
1794 }
1795 let _ = tx.send(text);
1797 Ok(())
1798 }
1799
1800 pub(crate) fn cancel_watch(&mut self, task_id: &str) -> CancelSignal {
1806 self.cancels.entry(task_id.to_string()).or_default().clone()
1807 }
1808
1809 pub(crate) fn is_canceled(&self, task_id: &str) -> bool {
1814 matches!(self.history.get(task_id), Some((TaskState::Canceled, _)))
1815 }
1816
1817 pub(crate) fn request_cancel(&mut self, task_id: &str) -> CancelOutcome {
1827 let Some((state, context_id)) = self.history.get(task_id).cloned() else {
1828 return CancelOutcome::Unknown;
1829 };
1830 if state.is_terminal() {
1831 return CancelOutcome::AlreadyTerminal;
1832 }
1833 if matches!(state, TaskState::Submitted) {
1834 self.pending_count = self.pending_count.saturating_sub(1);
1835 }
1836 self.record_state(task_id, TaskState::Canceled, context_id);
1837 self.cancel_watch(task_id).cancel();
1838 CancelOutcome::Accepted
1839 }
1840
1841 pub(crate) fn mark_standing_answer(&mut self, task_id: &str) {
1844 self.standing_answers.insert(task_id.to_string());
1845 }
1846
1847 pub(crate) fn clear_standing_answer(&mut self, task_id: &str) {
1850 self.standing_answers.remove(task_id);
1851 }
1852
1853 pub(crate) fn stopped_at_deadline(&self, task_id: &str) -> bool {
1856 self.stopped_at_deadline.contains(task_id)
1857 }
1858
1859 pub(crate) fn canceled_at_deadline(&self, task_id: &str) -> bool {
1862 self.canceled_at_deadline.contains(task_id)
1863 }
1864
1865 pub(crate) fn cancel_every_live(&mut self, at_deadline: bool) -> Vec<String> {
1877 let live: Vec<String> = self
1878 .history
1879 .iter()
1880 .filter(|(_, (state, _))| !state.is_terminal())
1881 .map(|(task_id, _)| task_id.clone())
1882 .collect();
1883 let (spared, live): (Vec<String>, Vec<String>) = live
1884 .into_iter()
1885 .partition(|task_id| at_deadline && self.standing_answers.contains(task_id));
1886 for task_id in spared {
1887 self.cancel_watch(&task_id).cancel();
1888 self.stopped_at_deadline.insert(task_id);
1889 }
1890 let mut canceled: Vec<String> = live
1891 .into_iter()
1892 .filter(|task_id| matches!(self.request_cancel(task_id), CancelOutcome::Accepted))
1893 .collect();
1894 if at_deadline {
1895 self.canceled_at_deadline.extend(canceled.iter().cloned());
1896 }
1897 canceled.sort();
1900 canceled
1901 }
1902
1903 pub(crate) fn close_to_new_work(&mut self) -> Vec<(String, String)> {
1911 self.closed = true;
1912 let mut refused: Vec<(String, String)> = self
1913 .history
1914 .iter()
1915 .filter(|(_, (state, _))| matches!(state, TaskState::Submitted))
1916 .map(|(task_id, (_, context_id))| (task_id.clone(), context_id.clone()))
1917 .collect();
1918 refused.sort();
1919 for (task_id, context_id) in &refused {
1920 self.record_state(task_id, TaskState::Rejected, context_id.clone());
1921 self.cancels.remove(task_id);
1922 }
1923 self.pending_count = self.pending_count.saturating_sub(refused.len());
1924 refused
1925 }
1926
1927 pub(crate) fn is_closed(&self) -> bool {
1930 self.closed
1931 }
1932
1933 pub(crate) fn has_ending(&self, task_id: &str) -> bool {
1936 self.endings.contains_key(task_id)
1937 }
1938
1939 #[allow(dead_code)] pub(crate) fn get_input_prompt(&self, task_id: &str) -> Option<&str> {
1942 self.input_waiters
1943 .get(task_id)
1944 .map(|(prompt, _)| prompt.as_str())
1945 }
1946
1947 pub(crate) fn get_task(&self, task_id: &str) -> Option<A2aTask> {
1954 self.history.get(task_id).map(|(state, context_id)| {
1955 let artifact =
1956 |name: &str, text: &str| A2aArtifact::named(name, vec![Part::text(text)]);
1957 let ending = self.endings.get(task_id).filter(|_| state.is_terminal());
1958 let artifacts = match state {
1959 TaskState::InputRequired => self
1960 .input_waiters
1961 .get(task_id)
1962 .map(|(prompt, _)| vec![artifact(PROMPT_ARTIFACT, prompt)]),
1963 TaskState::Completed => ending
1964 .and_then(|ending| ending.response.as_deref())
1965 .filter(|response| !response.is_empty())
1966 .map(|response| vec![artifact(RESPONSE_ARTIFACT, response)]),
1967 _ => None,
1968 };
1969 let message = ending
1970 .filter(|ending| !ending.message.is_empty())
1971 .map(|ending| StatusMessage::agent(task_id, context_id, &ending.message));
1972 A2aTask {
1973 id: task_id.to_string(),
1974 context_id: context_id.clone(),
1975 status: TaskStatus::at(
1976 state.clone(),
1977 message,
1978 self.status_time(task_id).unwrap_or_else(status_now),
1979 ),
1980 artifacts,
1981 metadata: ending.and_then(TaskEnding::metadata),
1982 }
1983 })
1984 }
1985
1986 pub(crate) fn reference_block(
1994 &self,
1995 reference_task_ids: &[String],
1996 visible: impl Fn(&str) -> bool,
1997 ) -> Option<String> {
1998 if reference_task_ids.is_empty() {
1999 return None;
2000 }
2001 let lines: Vec<String> = reference_task_ids
2002 .iter()
2003 .map(|task_id| {
2004 let Some((state, _)) = self.history.get(task_id).filter(|_| visible(task_id))
2005 else {
2006 return format!("- {task_id}: not a task this capsule holds");
2007 };
2008 let mut line = format!("- {task_id}: {}", state.as_str());
2009 let ending = self.endings.get(task_id).filter(|_| state.is_terminal());
2010 match (state, ending) {
2011 (TaskState::Completed, Some(ending)) => {
2012 if let Some(response) =
2013 ending.response.as_deref().filter(|text| !text.is_empty())
2014 {
2015 line.push('\n');
2016 line.push_str(&code_fence(RESPONSE_ARTIFACT, response));
2017 }
2018 }
2019 (_, Some(ending)) if !ending.message.is_empty() => {
2020 line.push_str(": ");
2021 line.push_str(&ending.message.replace(['\r', '\n'], " "));
2022 }
2023 _ => {}
2024 }
2025 line
2026 })
2027 .collect();
2028 Some(format!("Referenced tasks:\n{}", lines.join("\n")))
2029 }
2030
2031 pub(crate) fn list_tasks(
2039 &self,
2040 filter: &TaskFilter,
2041 after: Option<&ListCursor>,
2042 page_size: usize,
2043 visible: impl Fn(&str) -> bool,
2044 ) -> TaskPage {
2045 let mut matching: Vec<ListCursor> = self
2046 .history
2047 .iter()
2048 .filter(|(task_id, (state, context_id))| {
2049 filter.matches(state, context_id, self.status_time(task_id)) && visible(task_id)
2050 })
2051 .map(|(task_id, _)| ListCursor {
2052 revision: self.revisions.get(task_id).copied().unwrap_or_default(),
2053 task_id: task_id.clone(),
2054 })
2055 .collect();
2056 matching.sort_unstable_by(|a, b| b.cmp(a));
2057 let total = matching.len();
2058 let rest: Vec<ListCursor> = matching
2059 .into_iter()
2060 .filter(|position| after.is_none_or(|after| position < after))
2061 .collect();
2062 let page: Vec<&ListCursor> = rest.iter().take(page_size).collect();
2063 let next = (rest.len() > page.len())
2064 .then(|| page.last().map(|last| (*last).clone()))
2065 .flatten();
2066 TaskPage {
2067 tasks: page
2068 .iter()
2069 .filter_map(|position| self.get_task(&position.task_id))
2070 .collect(),
2071 next,
2072 total,
2073 }
2074 }
2075}
2076
2077#[derive(Debug, Clone, PartialEq)]
2079pub(crate) struct TaskFilter {
2080 pub context_id: Option<String>,
2082 pub state: StateFilter,
2083 pub status_after: Option<DateTime<Utc>>,
2086}
2087
2088impl TaskFilter {
2089 fn matches(&self, state: &TaskState, context_id: &str, at: Option<DateTime<Utc>>) -> bool {
2090 let in_context = self.context_id.as_deref().is_none_or(|c| c == context_id);
2091 let in_state = match &self.state {
2092 StateFilter::Any => true,
2093 StateFilter::Only(wanted) => wanted == state,
2094 StateFilter::Nothing => false,
2095 };
2096 let in_time = self
2097 .status_after
2098 .is_none_or(|after| at.is_some_and(|at| at >= after));
2099 in_context && in_state && in_time
2100 }
2101}
2102
2103#[derive(Debug, Clone, PartialEq)]
2105pub(crate) enum StateFilter {
2106 Any,
2108 Only(TaskState),
2110 Nothing,
2112}
2113
2114impl StateFilter {
2115 pub(crate) fn from_wire_name(wire: &str) -> Option<StateFilter> {
2118 match wire {
2119 "TASK_STATE_UNSPECIFIED" => Some(StateFilter::Any),
2120 "TASK_STATE_AUTH_REQUIRED" => Some(StateFilter::Nothing),
2121 wire => TaskState::from_wire_name(wire).map(StateFilter::Only),
2122 }
2123 }
2124}
2125
2126#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
2129pub(crate) struct ListCursor {
2130 pub revision: u64,
2131 pub task_id: String,
2132}
2133
2134impl ListCursor {
2135 pub(crate) fn encode(&self) -> String {
2138 use base64::Engine as _;
2139 base64::engine::general_purpose::URL_SAFE_NO_PAD
2140 .encode(format!("{}:{}", self.revision, self.task_id))
2141 }
2142
2143 pub(crate) fn decode(token: &str) -> Option<ListCursor> {
2145 use base64::Engine as _;
2146 let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
2147 .decode(token)
2148 .ok()?;
2149 let text = String::from_utf8(bytes).ok()?;
2150 let (revision, task_id) = text.split_once(':')?;
2151 let revision = revision
2152 .parse()
2153 .ok()
2154 .filter(|_| !revision.starts_with('+'))?;
2155 (!task_id.is_empty()).then(|| ListCursor {
2156 revision,
2157 task_id: task_id.to_string(),
2158 })
2159 }
2160}
2161
2162#[derive(Debug, Clone)]
2164pub(crate) struct TaskPage {
2165 pub tasks: Vec<A2aTask>,
2166 pub next: Option<ListCursor>,
2168 pub total: usize,
2170}
2171
2172#[derive(Debug)]
2175pub(crate) struct IncomingTask {
2176 pub task_id: String,
2177 pub context_id: String,
2178 pub message_id: String,
2179 pub message_text: String,
2180 pub traceparent: Option<String>,
2181 pub provenance: TaskProvenance,
2184 pub source: &'static str,
2189 pub forget_session: bool,
2194 pub caller_member: Option<String>,
2197 pub caller: Option<crate::identity::TaskCaller>,
2201 pub reference_task_ids: Vec<String>,
2204 pub part_kinds: Vec<&'static str>,
2207 pub images: Vec<(usize, crate::images::CheckedImage)>,
2210}
2211
2212pub(crate) const SOURCE_A2A: &str = "a2a";
2214
2215pub(crate) const SOURCE_TASK_MD: &str = "task_md";
2219
2220pub(crate) const SOURCE_DETACHED_SHELL: &str = "detached_shell";
2222
2223pub(crate) const SOURCE_DETACHED_LOST: &str = "detached_lost";
2227
2228#[cfg(test)]
2231mod tests {
2232 use super::*;
2233
2234 fn make_registry() -> TaskRegistry {
2235 TaskRegistry::new(1, TaskAcceptance::Single)
2236 }
2237
2238 fn running_registry(task_id: &str) -> TaskRegistry {
2239 let mut r = make_registry();
2240 r.enqueue(task_id, "ctx_001");
2241 r.start_task(task_id.to_string(), "ctx_001".to_string(), TaskLane::Bg);
2242 r
2243 }
2244
2245 #[test]
2246 fn set_input_required_transitions_state() {
2247 let mut r = running_registry("tsk_001");
2248 let (tx, _rx) = oneshot::channel();
2249 assert!(r
2250 .set_input_required("tsk_001", "which branch?".into(), tx)
2251 .is_ok());
2252 let task = r.get_task("tsk_001").unwrap();
2253 assert_eq!(task.status.state, TaskState::InputRequired);
2254 let artifacts = task.artifacts.unwrap();
2255 assert_eq!(artifacts[0].artifact_id, "prompt");
2256 assert_eq!(artifacts[0].first_text(), Some("which branch?"));
2257 }
2258
2259 #[test]
2260 fn set_input_required_fails_for_wrong_task() {
2261 let mut r = running_registry("tsk_001");
2262 let (tx, _rx) = oneshot::channel();
2263 assert!(r
2264 .set_input_required("tsk_other", "prompt".into(), tx)
2265 .is_err());
2266 }
2267
2268 #[test]
2269 fn deliver_input_transitions_back_to_working() {
2270 let mut r = running_registry("tsk_001");
2271 let (tx, mut rx) = oneshot::channel();
2272 r.set_input_required("tsk_001", "which branch?".into(), tx)
2273 .unwrap();
2274 r.deliver_input("tsk_001", "main".to_string()).unwrap();
2275 assert_eq!(rx.try_recv().unwrap(), "main");
2277 let task = r.get_task("tsk_001").unwrap();
2278 assert_eq!(task.status.state, TaskState::Working);
2279 assert!(task.artifacts.is_none());
2280 }
2281
2282 #[test]
2283 fn deliver_input_fails_when_not_input_required() {
2284 let mut r = running_registry("tsk_001");
2285 assert!(r.deliver_input("tsk_001", "text".into()).is_err());
2286 }
2287
2288 #[test]
2289 fn an_input_required_task_carries_its_prompt_and_nothing_else() {
2290 let mut r = running_registry("tsk_1");
2291 let (tx, _rx) = oneshot::channel();
2292 r.set_input_required("tsk_1", "Which file?".to_string(), tx)
2293 .unwrap();
2294 let task = r.get_task("tsk_1").unwrap();
2295 let artifacts = task.artifacts.unwrap();
2296 assert_eq!(artifacts.len(), 1);
2297 assert_eq!(
2298 serde_json::to_value(&artifacts[0]).unwrap(),
2299 serde_json::json!({"artifactId": "prompt", "name": "prompt",
2300 "parts": [{"text": "Which file?", "mediaType": "text/plain"}]})
2301 );
2302 assert!(task.status.message.is_none());
2303 }
2304
2305 #[test]
2307 fn a_working_task_carries_no_artifact_and_no_message() {
2308 let r = running_registry("tsk_1");
2309 let task = serde_json::to_value(r.get_task("tsk_1").unwrap()).unwrap();
2310 assert_bare_status(&task["status"], "TASK_STATE_WORKING");
2311 assert_eq!(
2312 task,
2313 serde_json::json!({"id": "tsk_1", "contextId": "ctx_001", "status": {
2314 "state": "TASK_STATE_WORKING",
2315 "timestamp": timestamp_of(&r, "tsk_1"),
2316 }})
2317 );
2318 }
2319
2320 #[test]
2323 fn a_completed_task_carries_its_response_artifact_and_status_message() {
2324 let mut r = running_registry("tsk_1");
2325 r.finish_task(TaskState::Completed);
2326 r.record_ending("tsk_1", "done", Some("the answer is 4"), false, &[], &[]);
2327 let task = serde_json::to_value(r.get_task("tsk_1").unwrap()).unwrap();
2328 assert_eq!(
2329 task,
2330 serde_json::json!({
2331 "id": "tsk_1",
2332 "contextId": "ctx_001",
2333 "status": {
2334 "state": "TASK_STATE_COMPLETED",
2335 "message": {
2336 "messageId": "msg_tsk_1_status",
2337 "contextId": "ctx_001",
2338 "taskId": "tsk_1",
2339 "role": "ROLE_AGENT",
2340 "parts": [{"text": "done", "mediaType": "text/plain"}],
2341 },
2342 "timestamp": status_timestamp(r.status_times["tsk_1"]),
2343 },
2344 "artifacts": [{
2345 "artifactId": "response",
2346 "name": "response",
2347 "parts": [{"text": "the answer is 4", "mediaType": "text/plain"}],
2348 }],
2349 })
2350 );
2351 }
2352
2353 #[test]
2357 fn a_task_ending_with_commands_running_names_them_in_its_metadata() {
2358 let long = "x".repeat(250);
2359 let running = [
2360 RunningCommand {
2361 work_id: "wrk_0a".to_string(),
2362 binary: "/usr/bin/bash".to_string(),
2363 command: "sleep 300".to_string(),
2364 running_ms: 12_000,
2365 },
2366 RunningCommand {
2367 work_id: "wrk_0b".to_string(),
2368 binary: "/usr/bin/bash".to_string(),
2369 command: long.clone(),
2370 running_ms: 3_000,
2371 },
2372 ];
2373 let mut r = running_registry("tsk_1");
2374 r.finish_task(TaskState::Completed);
2375 r.record_ending("tsk_1", "done", Some("4"), false, &[], &running);
2376 let task = serde_json::to_value(r.get_task("tsk_1").unwrap()).unwrap();
2377 assert_eq!(
2378 task["metadata"],
2379 serde_json::json!({"murmur": {"runningCommands": [
2380 {"workId": "wrk_0a", "binary": "/usr/bin/bash", "command": "sleep 300",
2381 "runningMs": 12_000},
2382 {"workId": "wrk_0b", "binary": "/usr/bin/bash",
2383 "command": format!("{}…", &long[..200]), "runningMs": 3_000},
2384 ]}})
2385 );
2386
2387 let mut r = running_registry("tsk_2");
2388 r.finish_task(TaskState::Completed);
2389 r.record_ending("tsk_2", "done", Some("4"), false, &[], &[]);
2390 let task = serde_json::to_value(r.get_task("tsk_2").unwrap()).unwrap();
2391 assert!(task.get("metadata").is_none(), "{task}");
2392 }
2393
2394 #[test]
2399 fn a_no_answer_ending_carries_its_metadata_and_no_other_ending_does() {
2400 let below = [("q".to_string(), "timed_out".to_string())];
2401 let mut r = running_registry("tsk_1");
2402 r.finish_task(TaskState::Failed);
2403 r.record_ending("tsk_1", "q gave no answer", None, true, &below, &[]);
2404 let task = serde_json::to_value(r.get_task("tsk_1").unwrap()).unwrap();
2405 assert_eq!(task["status"]["state"], "TASK_STATE_FAILED");
2406 assert_eq!(
2407 task["status"]["message"]["parts"][0]["text"],
2408 "q gave no answer"
2409 );
2410 assert_eq!(
2411 task["metadata"],
2412 serde_json::json!({"murmur": {
2413 "noAnswer": true,
2414 "noAnswerBelow": [{"member": "q", "status": "timed_out"}],
2415 }})
2416 );
2417
2418 let mut r = running_registry("tsk_2");
2419 r.finish_task(TaskState::Completed);
2420 r.record_ending(
2421 "tsk_2",
2422 "task completed",
2423 Some("none came"),
2424 false,
2425 &below,
2426 &[],
2427 );
2428 let task = serde_json::to_value(r.get_task("tsk_2").unwrap()).unwrap();
2429 assert_eq!(
2430 task["artifacts"],
2431 serde_json::json!([{"artifactId": "response", "name": "response",
2432 "parts": [{"text": "none came", "mediaType": "text/plain"}]}])
2433 );
2434 assert_eq!(
2435 task["metadata"],
2436 serde_json::json!({"murmur": {
2437 "noAnswerBelow": [{"member": "q", "status": "timed_out"}],
2438 }})
2439 );
2440
2441 for state in [TaskState::Completed, TaskState::Failed] {
2442 let mut r = running_registry("tsk_3");
2443 r.finish_task(state);
2444 r.record_ending("tsk_3", "ended", Some("4"), false, &[], &[]);
2445 let task = serde_json::to_value(r.get_task("tsk_3").unwrap()).unwrap();
2446 assert!(task.get("metadata").is_none(), "{task}");
2447 }
2448 let r = running_registry("tsk_4");
2449 let task = serde_json::to_value(r.get_task("tsk_4").unwrap()).unwrap();
2450 assert!(task.get("metadata").is_none(), "{task}");
2451 }
2452
2453 #[test]
2455 fn a_failed_task_carries_its_status_message_and_no_response() {
2456 let mut r = running_registry("tsk_1");
2457 r.finish_task(TaskState::Failed);
2458 r.record_ending(
2459 "tsk_1",
2460 "the driver failed",
2461 Some("partial"),
2462 false,
2463 &[],
2464 &[],
2465 );
2466 let task = r.get_task("tsk_1").unwrap();
2467 assert!(task.artifacts.is_none());
2468 let message = task.status.message.unwrap();
2469 assert_eq!(message.role, Role::Agent);
2470 assert_eq!(message.text(), Some("the driver failed"));
2471 }
2472
2473 #[test]
2475 fn a_completed_task_without_a_response_carries_no_artifact() {
2476 let mut r = running_registry("tsk_1");
2477 r.finish_task(TaskState::Completed);
2478 assert!(r.get_task("tsk_1").unwrap().artifacts.is_none());
2479 r.record_ending("tsk_1", "ok", Some(""), false, &[], &[]);
2480 let task = r.get_task("tsk_1").unwrap();
2481 assert!(task.artifacts.is_none());
2482 assert_eq!(task.status.message.unwrap().text(), Some("ok"));
2483 }
2484
2485 #[test]
2486 fn an_owner_is_recorded_per_task() {
2487 let mut r = make_registry();
2488 r.enqueue("tsk_a", "ctx_001");
2489 r.record_owner("tsk_a", "member:lead");
2490 r.enqueue("tsk_b", "ctx_001");
2491 assert_eq!(r.owner("tsk_a"), Some("member:lead"));
2492 assert_eq!(r.owner("tsk_b"), None);
2493 r.withdraw("tsk_a");
2494 assert_eq!(r.owner("tsk_a"), None);
2495 assert!(r.get_task("tsk_a").is_none());
2496 assert_eq!(r.pending_count, 1);
2497 }
2498
2499 fn listed(page: &TaskPage) -> Vec<&str> {
2501 page.tasks.iter().map(|task| task.id.as_str()).collect()
2502 }
2503
2504 fn every_task() -> TaskFilter {
2505 TaskFilter {
2506 context_id: None,
2507 state: StateFilter::Any,
2508 status_after: None,
2509 }
2510 }
2511
2512 #[test]
2514 fn list_tasks_orders_by_the_last_change_newest_first() {
2515 let mut r = TaskRegistry::new(8, TaskAcceptance::Queue);
2516 for id in ["tsk_a", "tsk_b", "tsk_c"] {
2517 r.enqueue(id, "ctx_001");
2518 }
2519 let all = |r: &TaskRegistry| -> Vec<String> {
2520 listed(&r.list_tasks(&every_task(), None, 10, |_| true))
2521 .into_iter()
2522 .map(str::to_string)
2523 .collect()
2524 };
2525 assert_eq!(all(&r), ["tsk_c", "tsk_b", "tsk_a"]);
2526 r.start_task("tsk_a".to_string(), "ctx_001".to_string(), TaskLane::User);
2527 assert_eq!(all(&r), ["tsk_a", "tsk_c", "tsk_b"]);
2528 r.request_cancel("tsk_b");
2529 assert_eq!(all(&r), ["tsk_b", "tsk_a", "tsk_c"]);
2530 let (tx, _rx) = oneshot::channel();
2531 r.set_input_required("tsk_a", "which?".to_string(), tx)
2532 .unwrap();
2533 assert_eq!(all(&r), ["tsk_a", "tsk_b", "tsk_c"]);
2534 r.deliver_input("tsk_a", "left".to_string()).unwrap();
2535 r.finish_task(TaskState::Completed);
2536 r.record_ending("tsk_c", "", None, false, &[], &[]);
2537 assert_eq!(all(&r), ["tsk_c", "tsk_a", "tsk_b"]);
2538 r.record_ending("tsk_a", "done", Some("four"), false, &[], &[]);
2539 assert_eq!(all(&r), ["tsk_a", "tsk_c", "tsk_b"]);
2540 r.close_to_new_work();
2541 assert_eq!(all(&r), ["tsk_c", "tsk_a", "tsk_b"], "tsk_c was rejected");
2542
2543 let page = r.list_tasks(&every_task(), None, 10, |_| true);
2544 for task in &page.tasks {
2545 assert_eq!(
2546 serde_json::to_value(task).unwrap(),
2547 serde_json::to_value(r.get_task(&task.id).unwrap()).unwrap()
2548 );
2549 }
2550 }
2551
2552 #[test]
2555 fn list_tasks_walks_pages_and_returns_a_task_changed_mid_walk_once() {
2556 let mut r = TaskRegistry::new(8, TaskAcceptance::Queue);
2557 for id in ["tsk_1", "tsk_2", "tsk_3", "tsk_4", "tsk_5"] {
2558 r.enqueue(id, "ctx_001");
2559 }
2560 let first = r.list_tasks(&every_task(), None, 2, |_| true);
2561 assert_eq!(listed(&first), ["tsk_5", "tsk_4"]);
2562 assert_eq!(first.total, 5);
2563 let cursor = first.next.clone().expect("more tasks lie past the page");
2564 assert_eq!(ListCursor::decode(&cursor.encode()), Some(cursor.clone()));
2565
2566 r.request_cancel("tsk_4");
2567 r.request_cancel("tsk_2");
2568 let second = r.list_tasks(&every_task(), Some(&cursor), 2, |_| true);
2569 assert_eq!(listed(&second), ["tsk_3", "tsk_1"], "tsk_2 moved ahead");
2570 assert_eq!(second.next, None);
2571 assert_eq!(second.total, 5);
2572 let fresh = r.list_tasks(&every_task(), None, 2, |_| true);
2573 assert_eq!(listed(&fresh), ["tsk_2", "tsk_4"]);
2574
2575 let exact = r.list_tasks(&every_task(), None, 5, |_| true);
2576 assert_eq!(exact.tasks.len(), 5);
2577 assert_eq!(
2578 exact.next, None,
2579 "no task lies past a page that ends the list"
2580 );
2581 }
2582
2583 fn instant(timestamp: &str) -> DateTime<Utc> {
2585 DateTime::parse_from_rfc3339(timestamp)
2586 .unwrap()
2587 .with_timezone(&Utc)
2588 }
2589
2590 fn timestamp_of(r: &TaskRegistry, task_id: &str) -> String {
2591 r.get_task(task_id).unwrap().status.timestamp.unwrap()
2592 }
2593
2594 fn a_tick() {
2596 std::thread::sleep(std::time::Duration::from_millis(3));
2597 }
2598
2599 #[test]
2602 fn a_status_timestamp_moves_with_the_revision() {
2603 let mut r = TaskRegistry::new(8, TaskAcceptance::Queue);
2604 r.enqueue("tsk_a", "ctx_001");
2605 let submitted = timestamp_of(&r, "tsk_a");
2606 let submitted_revision = r.revisions["tsk_a"];
2607 assert_eq!(r.status_times["tsk_a"], instant(&submitted));
2608
2609 a_tick();
2610 r.record_owner("tsk_a", "alice");
2611 assert_eq!(
2612 timestamp_of(&r, "tsk_a"),
2613 submitted,
2614 "an owner is no change"
2615 );
2616 assert_eq!(r.revisions["tsk_a"], submitted_revision);
2617
2618 r.start_task("tsk_a".to_string(), "ctx_001".to_string(), TaskLane::User);
2619 let working = timestamp_of(&r, "tsk_a");
2620 assert!(r.revisions["tsk_a"] > submitted_revision);
2621 assert!(
2622 instant(&working) > instant(&submitted),
2623 "{submitted} → {working}"
2624 );
2625
2626 a_tick();
2627 r.finish_task(TaskState::Completed);
2628 let completed_revision = r.revisions["tsk_a"];
2629 let completed = timestamp_of(&r, "tsk_a");
2630 assert!(instant(&completed) > instant(&working));
2631
2632 a_tick();
2633 r.record_ending("tsk_a", "done", Some("four"), false, &[], &[]);
2634 assert!(r.revisions["tsk_a"] > completed_revision);
2635 assert!(instant(×tamp_of(&r, "tsk_a")) > instant(&completed));
2636 }
2637
2638 #[test]
2641 fn a_status_timestamp_round_trips_and_a_cutoff_equal_to_it_includes_the_task() {
2642 let mut r = TaskRegistry::new(8, TaskAcceptance::Queue);
2643 r.enqueue("tsk_a", "ctx_001");
2644 let timestamp = timestamp_of(&r, "tsk_a");
2645 assert!(timestamp.ends_with('Z'), "{timestamp}");
2646 assert_eq!(
2647 timestamp.len(),
2648 "2026-10-13T09:00:00.123Z".len(),
2649 "{timestamp}"
2650 );
2651 assert_eq!(instant(×tamp), r.status_times["tsk_a"]);
2652 let at = TaskFilter {
2653 status_after: Some(instant(×tamp)),
2654 ..every_task()
2655 };
2656 assert_eq!(listed(&r.list_tasks(&at, None, 10, |_| true)), ["tsk_a"]);
2657 let after = TaskFilter {
2658 status_after: Some(instant(×tamp) + chrono::Duration::milliseconds(1)),
2659 ..every_task()
2660 };
2661 assert!(r.list_tasks(&after, None, 10, |_| true).tasks.is_empty());
2662 }
2663
2664 #[test]
2665 fn withdraw_drops_the_status_time() {
2666 let mut r = TaskRegistry::new(8, TaskAcceptance::Queue);
2667 r.enqueue("tsk_a", "ctx_001");
2668 r.withdraw("tsk_a");
2669 assert!(!r.status_times.contains_key("tsk_a"));
2670 assert!(r.get_task("tsk_a").is_none());
2671 }
2672
2673 #[test]
2676 fn list_tasks_filters_by_status_time_before_paging() {
2677 let mut r = TaskRegistry::new(8, TaskAcceptance::Queue);
2678 for id in ["tsk_1", "tsk_2", "tsk_3", "tsk_4"] {
2679 r.enqueue(id, "ctx_001");
2680 }
2681 r.request_cancel("tsk_3");
2682 let base = instant("2026-10-13T09:00:00Z");
2683 for (offset, id) in ["tsk_1", "tsk_2", "tsk_3", "tsk_4"].iter().enumerate() {
2684 r.status_times.insert(
2685 id.to_string(),
2686 base + chrono::Duration::seconds(offset as i64),
2687 );
2688 }
2689 let since = |seconds: i64| TaskFilter {
2690 status_after: Some(base + chrono::Duration::seconds(seconds)),
2691 ..every_task()
2692 };
2693 let first = r.list_tasks(&since(1), None, 2, |_| true);
2694 assert_eq!(listed(&first), ["tsk_3", "tsk_4"]);
2695 assert_eq!(first.total, 3);
2696 let second = r.list_tasks(&since(1), first.next.as_ref(), 2, |_| true);
2697 assert_eq!(listed(&second), ["tsk_2"]);
2698 assert_eq!((second.total, second.next), (3, None));
2699
2700 let canceled_since = TaskFilter {
2701 state: StateFilter::Only(TaskState::Canceled),
2702 ..since(1)
2703 };
2704 let page = r.list_tasks(&canceled_since, None, 10, |_| true);
2705 assert_eq!((listed(&page), page.total), (vec!["tsk_3"], 1));
2706 assert_eq!(r.list_tasks(&since(4), None, 10, |_| true).total, 0);
2707 assert_eq!(r.list_tasks(&every_task(), None, 10, |_| true).total, 4);
2708 }
2709
2710 #[test]
2712 fn list_tasks_filters_by_context_state_and_visibility() {
2713 let mut r = TaskRegistry::new(8, TaskAcceptance::Queue);
2714 r.enqueue("tsk_a", "ctx_one");
2715 r.record_owner("tsk_a", "alice");
2716 r.enqueue("tsk_b", "ctx_two");
2717 r.record_owner("tsk_b", "alice");
2718 r.enqueue("tsk_c", "ctx_one");
2719 r.record_owner("tsk_c", "bob");
2720 r.request_cancel("tsk_c");
2721 let alice = |task_id: &str| r.owner(task_id) == Some("alice");
2722 let page = r.list_tasks(&every_task(), None, 10, alice);
2723 assert_eq!((listed(&page), page.total), (vec!["tsk_b", "tsk_a"], 2));
2724 let in_one = TaskFilter {
2725 context_id: Some("ctx_one".to_string()),
2726 state: StateFilter::Any,
2727 status_after: None,
2728 };
2729 let page = r.list_tasks(&in_one, None, 10, |_| true);
2730 assert_eq!((listed(&page), page.total), (vec!["tsk_c", "tsk_a"], 2));
2731 let canceled = TaskFilter {
2732 context_id: None,
2733 state: StateFilter::Only(TaskState::Canceled),
2734 status_after: None,
2735 };
2736 let page = r.list_tasks(&canceled, None, 10, |_| true);
2737 assert_eq!(listed(&page), ["tsk_c"]);
2738 let page = r.list_tasks(&canceled, None, 10, alice);
2739 assert_eq!((page.tasks.len(), page.total, page.next), (0, 0, None));
2740 let nothing = TaskFilter {
2741 context_id: None,
2742 state: StateFilter::Nothing,
2743 status_after: None,
2744 };
2745 assert_eq!(r.list_tasks(¬hing, None, 10, |_| true).total, 0);
2746 }
2747
2748 #[test]
2749 fn list_cursor_reads_only_what_it_wrote() {
2750 let cursor = ListCursor {
2751 revision: 42,
2752 task_id: "tsk_01a1".to_string(),
2753 };
2754 let token = cursor.encode();
2755 assert!(!token.contains('='), "{token}");
2756 assert_eq!(ListCursor::decode(&token), Some(cursor));
2757 use base64::Engine as _;
2758 let encode = |text: &str| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(text);
2759 for garbage in [
2760 "not-a-token".to_string(),
2761 "".to_string(),
2762 "!!".to_string(),
2763 encode("42"),
2764 encode("42:"),
2765 encode("x:tsk_1"),
2766 encode("-1:tsk_1"),
2767 encode("+1:tsk_1"),
2768 ] {
2769 assert_eq!(ListCursor::decode(&garbage), None, "{garbage}");
2770 }
2771 }
2772
2773 #[test]
2774 fn list_state_filter_reads_every_v1_state_name() {
2775 for state in TaskState::ALL {
2776 assert_eq!(
2777 TaskState::from_wire_name(state.wire_name()),
2778 Some(state.clone())
2779 );
2780 assert_eq!(
2781 StateFilter::from_wire_name(state.wire_name()),
2782 Some(StateFilter::Only(state))
2783 );
2784 }
2785 assert_eq!(
2786 StateFilter::from_wire_name("TASK_STATE_UNSPECIFIED"),
2787 Some(StateFilter::Any)
2788 );
2789 assert_eq!(
2790 StateFilter::from_wire_name("TASK_STATE_AUTH_REQUIRED"),
2791 Some(StateFilter::Nothing)
2792 );
2793 for other in ["done", "completed", "task_state_completed", ""] {
2794 assert_eq!(StateFilter::from_wire_name(other), None, "{other}");
2795 assert_eq!(TaskState::from_wire_name(other), None, "{other}");
2796 }
2797 }
2798
2799 #[test]
2800 fn request_cancel_on_a_running_task_records_canceled() {
2801 let mut r = running_registry("tsk_001");
2802 let signal = r.cancel_watch("tsk_001");
2803 assert_eq!(r.request_cancel("tsk_001"), CancelOutcome::Accepted);
2804 assert!(signal.is_canceled());
2805 assert!(r.is_canceled("tsk_001"));
2806 let task = r.get_task("tsk_001").unwrap();
2807 assert_eq!(task.status.state, TaskState::Canceled);
2808 }
2809
2810 #[test]
2811 fn request_cancel_on_a_completed_task_leaves_it_alone() {
2812 let mut r = running_registry("tsk_001");
2813 r.finish_task(TaskState::Completed);
2814 assert_eq!(r.request_cancel("tsk_001"), CancelOutcome::AlreadyTerminal);
2815 assert_eq!(
2816 r.get_task("tsk_001").unwrap().status.state,
2817 TaskState::Completed
2818 );
2819 let mut r = running_registry("tsk_002");
2821 assert_eq!(r.request_cancel("tsk_002"), CancelOutcome::Accepted);
2822 assert_eq!(r.request_cancel("tsk_002"), CancelOutcome::AlreadyTerminal);
2823 }
2824
2825 #[test]
2826 fn cancel_every_live_cancels_live_tasks_in_order_and_leaves_terminal_ones() {
2827 let mut r = TaskRegistry::new(8, TaskAcceptance::Queue);
2828 r.enqueue("tsk_c", "ctx_001");
2829 r.enqueue("tsk_a", "ctx_001");
2830 r.enqueue("tsk_done", "ctx_001");
2831 r.start_task("tsk_done".to_string(), "ctx_001".to_string(), TaskLane::Bg);
2832 r.finish_task(TaskState::Completed);
2833 r.start_task("tsk_a".to_string(), "ctx_001".to_string(), TaskLane::Bg);
2834 let running = r.cancel_watch("tsk_a");
2835
2836 assert_eq!(r.cancel_every_live(false), vec!["tsk_a", "tsk_c"]);
2837 assert!(running.is_canceled());
2838 assert!(r.is_canceled("tsk_c"));
2839 assert_eq!(
2840 r.get_task("tsk_done").unwrap().status.state,
2841 TaskState::Completed
2842 );
2843 assert!(r.cancel_every_live(false).is_empty());
2844 }
2845
2846 #[test]
2849 fn cancel_every_live_at_the_deadline_spares_a_task_holding_a_standing_answer() {
2850 let mut r = TaskRegistry::new(8, TaskAcceptance::Queue);
2851 r.enqueue("tsk_a", "ctx_001");
2852 r.enqueue("tsk_b", "ctx_001");
2853 r.start_task("tsk_a".to_string(), "ctx_001".to_string(), TaskLane::Bg);
2854 let running = r.cancel_watch("tsk_a");
2855 r.mark_standing_answer("tsk_a");
2856
2857 assert_eq!(r.cancel_every_live(true), vec!["tsk_b"]);
2858 assert!(running.is_canceled());
2859 assert!(!r.is_canceled("tsk_a"));
2860 assert!(!r.get_task("tsk_a").unwrap().status.state.is_terminal());
2861 assert!(r.stopped_at_deadline("tsk_a"));
2862 assert!(
2863 r.is_canceled("tsk_b"),
2864 "an unmarked task is cancelled at once"
2865 );
2866
2867 assert_eq!(
2868 r.finish_task(TaskState::Completed),
2869 Some(TaskState::Completed)
2870 );
2871 assert_eq!(
2872 r.get_task("tsk_a").unwrap().status.state,
2873 TaskState::Completed
2874 );
2875 assert!(
2876 !r.stopped_at_deadline("tsk_a"),
2877 "finish_task clears the mark"
2878 );
2879 }
2880
2881 #[test]
2884 fn cancel_every_live_at_the_deadline_marks_the_tasks_it_cancels() {
2885 let mut r = TaskRegistry::new(8, TaskAcceptance::Queue);
2886 r.enqueue("tsk_a", "ctx_001");
2887 r.enqueue("tsk_b", "ctx_001");
2888 r.start_task("tsk_a".to_string(), "ctx_001".to_string(), TaskLane::Bg);
2889 assert_eq!(r.request_cancel("tsk_b"), CancelOutcome::Accepted);
2890
2891 assert_eq!(r.cancel_every_live(true), vec!["tsk_a"]);
2892 assert!(r.canceled_at_deadline("tsk_a"));
2893 assert!(
2894 !r.canceled_at_deadline("tsk_b"),
2895 "a person's cancel came first"
2896 );
2897 assert_eq!(
2898 r.finish_task(TaskState::Completed),
2899 Some(TaskState::Canceled)
2900 );
2901 assert!(
2902 !r.canceled_at_deadline("tsk_a"),
2903 "finish_task clears the mark"
2904 );
2905
2906 let mut r = running_registry("tsk_001");
2907 assert_eq!(r.cancel_every_live(false), vec!["tsk_001"]);
2908 assert!(!r.canceled_at_deadline("tsk_001"));
2909 }
2910
2911 #[test]
2913 fn cancel_every_live_without_sparing_cancels_a_task_holding_a_standing_answer() {
2914 let mut r = running_registry("tsk_001");
2915 r.mark_standing_answer("tsk_001");
2916 assert_eq!(r.cancel_every_live(false), vec!["tsk_001"]);
2917 assert!(r.is_canceled("tsk_001"));
2918 assert!(!r.stopped_at_deadline("tsk_001"));
2919 assert_eq!(
2920 r.finish_task(TaskState::Completed),
2921 Some(TaskState::Canceled)
2922 );
2923 }
2924
2925 #[test]
2927 fn cancel_every_live_at_the_deadline_cancels_a_task_whose_mark_was_cleared() {
2928 let mut r = running_registry("tsk_001");
2929 r.mark_standing_answer("tsk_001");
2930 r.clear_standing_answer("tsk_001");
2931 assert_eq!(r.cancel_every_live(true), vec!["tsk_001"]);
2932 assert!(r.is_canceled("tsk_001"));
2933 assert!(!r.stopped_at_deadline("tsk_001"));
2934 }
2935
2936 #[test]
2937 fn request_cancel_on_an_unknown_id_is_unknown() {
2938 let mut r = make_registry();
2939 assert_eq!(r.request_cancel("tsk_doesnotexist"), CancelOutcome::Unknown);
2940 assert!(!r.is_canceled("tsk_doesnotexist"));
2941 }
2942
2943 #[test]
2944 fn finish_task_does_not_overwrite_an_accepted_cancel() {
2945 let mut r = running_registry("tsk_001");
2946 assert_eq!(r.request_cancel("tsk_001"), CancelOutcome::Accepted);
2947 assert_eq!(
2948 r.finish_task(TaskState::Completed),
2949 Some(TaskState::Canceled),
2950 "the state recorded is the accepted cancel, and the caller is told so"
2951 );
2952 assert_eq!(
2953 r.get_task("tsk_001").unwrap().status.state,
2954 TaskState::Canceled
2955 );
2956 assert_eq!(
2957 r.finish_task(TaskState::Failed),
2958 None,
2959 "a slot that is no longer running records nothing"
2960 );
2961 assert_eq!(
2962 r.get_task("tsk_001").unwrap().status.state,
2963 TaskState::Canceled
2964 );
2965 }
2966
2967 #[test]
2968 fn finish_task_returns_the_state_it_recorded() {
2969 let mut r = running_registry("tsk_001");
2970 assert_eq!(
2971 r.finish_task(TaskState::Completed),
2972 Some(TaskState::Completed)
2973 );
2974 assert_eq!(make_registry().finish_task(TaskState::Failed), None);
2975 }
2976
2977 #[test]
2978 fn an_input_timeout_leaves_the_task_working_and_is_read_once() {
2979 let mut r = running_registry("tsk_001");
2980 let (tx, _rx) = oneshot::channel();
2981 r.set_input_required("tsk_001", "prompt".into(), tx)
2982 .unwrap();
2983 r.record_input_timeout("tsk_001");
2984 assert_eq!(
2985 r.get_task("tsk_001").unwrap().status.state,
2986 TaskState::Working,
2987 "the task's terminal state is not the wait's to record"
2988 );
2989 assert!(
2990 r.get_input_prompt("tsk_001").is_none(),
2991 "the waiter is gone"
2992 );
2993 assert!(r.take_input_timeout("tsk_001"));
2994 assert!(
2995 !r.take_input_timeout("tsk_001"),
2996 "a reopened attempt does not end on the same timeout"
2997 );
2998 assert!(!r.take_input_timeout("tsk_other"));
2999 }
3000
3001 #[test]
3002 fn cancelling_a_queued_task_frees_its_queue_slot() {
3003 let mut r = TaskRegistry::new(2, TaskAcceptance::Queue);
3004 r.enqueue("tsk_a", "ctx_001");
3005 r.enqueue("tsk_b", "ctx_001");
3006 assert!(!r.can_accept(), "the queue is full");
3007 assert_eq!(r.request_cancel("tsk_b"), CancelOutcome::Accepted);
3008 assert!(r.can_accept(), "a cancelled task holds no slot");
3009 }
3010
3011 #[test]
3012 fn close_to_new_work_rejects_every_submitted_task_and_nothing_else() {
3013 let mut r = TaskRegistry::new(4, TaskAcceptance::Queue);
3014 for id in ["tsk_a", "tsk_b", "tsk_c", "tsk_d"] {
3015 r.enqueue(id, &format!("ctx_{id}"));
3016 }
3017 r.start_task("tsk_a".to_string(), "ctx_tsk_a".to_string(), TaskLane::Bg);
3018 let (tx, _rx) = oneshot::channel();
3019 r.set_input_required("tsk_a", "prompt".into(), tx).unwrap();
3020 assert_eq!(r.request_cancel("tsk_c"), CancelOutcome::Accepted);
3021 let pending_before = r.pending_count;
3022
3023 let refused = r.close_to_new_work();
3024
3025 assert_eq!(
3026 refused,
3027 vec![
3028 ("tsk_b".to_string(), "ctx_tsk_b".to_string()),
3029 ("tsk_d".to_string(), "ctx_tsk_d".to_string()),
3030 ]
3031 );
3032 for id in ["tsk_b", "tsk_d"] {
3033 assert_eq!(r.get_task(id).unwrap().status.state, TaskState::Rejected);
3034 }
3035 assert_eq!(r.pending_count, pending_before - 2);
3036 assert_eq!(r.pending_count, 0);
3037 assert_eq!(
3038 r.get_task("tsk_a").unwrap().status.state,
3039 TaskState::InputRequired,
3040 "a live task that is not submitted is left alone"
3041 );
3042 assert_eq!(
3043 r.get_task("tsk_c").unwrap().status.state,
3044 TaskState::Canceled
3045 );
3046 assert_eq!(r.request_cancel("tsk_b"), CancelOutcome::AlreadyTerminal);
3047 assert!(!r.is_canceled("tsk_b"));
3048 }
3049
3050 #[test]
3051 fn a_closed_registry_accepts_nothing_and_closes_once() {
3052 for (depth, mode) in [(1, TaskAcceptance::Single), (4, TaskAcceptance::Queue)] {
3053 let mut r = TaskRegistry::new(depth, mode.clone());
3054 assert!(r.can_accept());
3055 assert!(!r.is_closed());
3056 r.enqueue("tsk_a", "ctx_001");
3057 assert_eq!(r.close_to_new_work().len(), 1);
3058 assert!(r.is_closed());
3059 assert!(!r.can_accept(), "{mode:?}: a closed registry refuses work");
3060 assert!(r.close_to_new_work().is_empty(), "{mode:?}: closes once");
3061 assert!(!r.can_accept());
3062 }
3063 }
3064
3065 #[test]
3070 fn tasks_held_at_once_matches_what_the_registry_accepts() {
3071 const SAFETY_CAP: usize = 64;
3072 for (mode, depth, expected) in [
3073 (TaskAcceptance::None, 1, 0),
3074 (TaskAcceptance::Single, 1, 1),
3075 (TaskAcceptance::Queue, 1, 2),
3076 (TaskAcceptance::Queue, 3, 4),
3077 ] {
3078 let mut r = TaskRegistry::new(depth, mode.clone());
3079 let mut accepted = 0;
3080 while r.can_accept() && accepted < SAFETY_CAP {
3081 let task_id = format!("tsk_{accepted}");
3082 r.enqueue(&task_id, "ctx_001");
3083 if accepted == 0 {
3084 r.start_task(task_id, "ctx_001".to_string(), TaskLane::Peer);
3085 }
3086 accepted += 1;
3087 }
3088 let lifecycle = murmur_artifact::LifecycleConfig {
3089 task_acceptance: mode.clone(),
3090 queue_depth: depth,
3091 ..Default::default()
3092 };
3093 assert_eq!(accepted, expected, "{mode:?} at depth {depth}");
3094 assert_eq!(
3095 accepted,
3096 lifecycle.tasks_held_at_once(),
3097 "{mode:?} at depth {depth}: tasks_held_at_once drifted from the registry"
3098 );
3099 }
3100 }
3101
3102 #[test]
3103 fn task_state_serializes_as_its_wire_name_and_reads_only_that() {
3104 for state in TaskState::ALL {
3105 let wire = serde_json::to_value(&state).unwrap();
3106 assert_eq!(wire, serde_json::json!(state.wire_name()));
3107 assert_eq!(
3108 serde_json::from_value::<TaskState>(wire).unwrap(),
3109 state,
3110 "{state:?}"
3111 );
3112 assert!(
3113 serde_json::from_value::<TaskState>(serde_json::json!(state.as_str())).is_err(),
3114 "{state:?}: murmur's word is not a wire state"
3115 );
3116 }
3117 for refused in [
3118 serde_json::json!("TASK_STATE_UNSPECIFIED"),
3119 serde_json::json!("TASK_STATE_AUTH_REQUIRED"),
3120 serde_json::json!("task_state_working"),
3121 serde_json::json!(1),
3122 serde_json::json!(null),
3123 ] {
3124 assert!(
3125 serde_json::from_value::<TaskState>(refused.clone()).is_err(),
3126 "{refused}"
3127 );
3128 }
3129 }
3130
3131 #[test]
3132 fn task_state_as_str_is_murmurs_word() {
3133 let words: Vec<&str> = TaskState::ALL.iter().map(TaskState::as_str).collect();
3134 assert_eq!(
3135 words,
3136 [
3137 "submitted",
3138 "working",
3139 "completed",
3140 "failed",
3141 "canceled",
3142 "input-required",
3143 "rejected"
3144 ]
3145 );
3146 }
3147
3148 #[test]
3149 fn murmur_state_name_maps_the_eight_v1_states_and_nothing_else() {
3150 for state in TaskState::ALL {
3151 assert_eq!(murmur_state_name(state.wire_name()), Some(state.as_str()));
3152 }
3153 assert_eq!(
3154 murmur_state_name("TASK_STATE_AUTH_REQUIRED"),
3155 Some("auth-required")
3156 );
3157 for other in [
3158 "TASK_STATE_UNSPECIFIED",
3159 "working",
3160 "",
3161 "TASK_STATE_working",
3162 ] {
3163 assert_eq!(murmur_state_name(other), None, "{other:?}");
3164 }
3165 }
3166
3167 #[test]
3168 fn roles_serialize_as_their_proto_names() {
3169 assert_eq!(
3170 serde_json::to_value(Role::User).unwrap(),
3171 serde_json::json!("ROLE_USER")
3172 );
3173 assert_eq!(
3174 serde_json::to_value(Role::Agent).unwrap(),
3175 serde_json::json!("ROLE_AGENT")
3176 );
3177 assert!(serde_json::from_value::<Role>(serde_json::json!("agent")).is_err());
3178 }
3179
3180 #[test]
3181 fn finish_task_cleans_up_input_waiters() {
3182 let mut r = running_registry("tsk_001");
3183 let (tx, _rx) = oneshot::channel();
3184 r.set_input_required("tsk_001", "prompt".into(), tx)
3185 .unwrap();
3186 r.finish_task(TaskState::Failed);
3187 assert!(r.get_input_prompt("tsk_001").is_none());
3189 }
3190
3191 #[test]
3192 fn a2a_error_table_is_the_spec_table_in_code_order() {
3193 let table: Vec<(i32, &str)> = A2aError::ALL
3194 .into_iter()
3195 .map(|error| (error.code(), error.reason()))
3196 .collect();
3197 assert_eq!(
3198 table,
3199 [
3200 (-32001, "TASK_NOT_FOUND"),
3201 (-32002, "TASK_NOT_CANCELABLE"),
3202 (-32003, "PUSH_NOTIFICATION_NOT_SUPPORTED"),
3203 (-32004, "UNSUPPORTED_OPERATION"),
3204 (-32005, "CONTENT_TYPE_NOT_SUPPORTED"),
3205 (-32006, "INVALID_AGENT_RESPONSE"),
3206 (-32007, "EXTENDED_AGENT_CARD_NOT_CONFIGURED"),
3207 (-32008, "EXTENSION_SUPPORT_REQUIRED"),
3208 (-32009, "VERSION_NOT_SUPPORTED"),
3209 ]
3210 );
3211 }
3212
3213 #[test]
3214 fn a2a_error_from_code_round_trips_and_knows_no_other_code() {
3215 for error in A2aError::ALL {
3216 assert_eq!(A2aError::from_code(error.code()), Some(error));
3217 }
3218 for code in [-32000, -32010, -32099, -32600, -31001, 0] {
3219 assert_eq!(A2aError::from_code(code), None, "{code}");
3220 }
3221 }
3222
3223 #[test]
3224 fn a2a_murmur_errors_lie_outside_the_reserved_range() {
3225 let table: Vec<(i32, &str)> = MurmurError::ALL
3226 .into_iter()
3227 .map(|error| (error.code(), error.reason()))
3228 .collect();
3229 assert_eq!(
3230 table,
3231 [
3232 (-31001, "COMPLETION_MISADDRESSED"),
3233 (-31002, "COMPLETION_NOT_AWAITED")
3234 ]
3235 );
3236 for error in MurmurError::ALL {
3237 assert!(!(-32768..=-32000).contains(&error.code()), "{error:?}");
3238 }
3239 }
3240
3241 fn wire(response: &JsonRpcResponse) -> Value {
3242 serde_json::to_value(response).unwrap()
3243 }
3244
3245 #[test]
3246 fn a2a_error_carries_one_error_info_with_string_metadata() {
3247 let response = JsonRpcResponse::a2a_error(
3248 Value::from(7),
3249 A2aError::TaskNotCancelable,
3250 "Task cannot be canceled",
3251 &[("taskId", "tsk_1".into()), ("state", "completed".into())],
3252 );
3253 assert_eq!(
3254 wire(&response),
3255 serde_json::json!({
3256 "jsonrpc": "2.0",
3257 "id": 7,
3258 "error": {
3259 "code": -32002,
3260 "message": "Task cannot be canceled",
3261 "data": [{
3262 "@type": "type.googleapis.com/google.rpc.ErrorInfo",
3263 "reason": "TASK_NOT_CANCELABLE",
3264 "domain": "a2a-protocol.org",
3265 "metadata": {"taskId": "tsk_1", "state": "completed"},
3266 }],
3267 },
3268 })
3269 );
3270 }
3271
3272 #[test]
3273 fn a2a_murmur_error_is_in_the_murmur_domain() {
3274 let response = JsonRpcResponse::murmur_error(
3275 Value::from("a"),
3276 MurmurError::CompletionNotAwaited,
3277 "nobody waits",
3278 &[],
3279 );
3280 let error = &wire(&response)["error"];
3281 assert_eq!(error["code"], -31002);
3282 assert_eq!(error["data"][0]["reason"], "COMPLETION_NOT_AWAITED");
3283 assert_eq!(error["data"][0]["domain"], "murmur.nexus");
3284 assert_eq!(error["data"][0]["metadata"], serde_json::json!({}));
3285 }
3286
3287 #[test]
3288 fn a2a_standard_errors_carry_no_data() {
3289 let error = &wire(&JsonRpcResponse::err(
3290 Value::Null,
3291 -32601,
3292 "Method not found",
3293 ))["error"];
3294 assert!(error.get("data").is_none(), "{error}");
3295 }
3296
3297 fn refusal(body: &str) -> Value {
3298 wire(&JsonRpcRequest::from_body(body).expect_err(body))
3299 }
3300
3301 #[test]
3302 fn a2a_from_body_reads_a_request_and_defaults_params() {
3303 let req = JsonRpcRequest::from_body(
3304 r#"{"jsonrpc":"2.0","id":"x","method":"GetExtendedAgentCard","extra":1}"#,
3305 )
3306 .unwrap();
3307 assert_eq!(req.id, Value::from("x"));
3308 assert_eq!(req.method, "GetExtendedAgentCard");
3309 assert_eq!(req.params, serde_json::json!({}));
3310
3311 let req = JsonRpcRequest::from_body(
3312 r#"{"jsonrpc":"2.0","id":3,"method":"GetTask","params":[1]}"#,
3313 )
3314 .unwrap();
3315 assert_eq!(req.id, Value::from(3));
3316 assert_eq!(req.params, serde_json::json!([1]), "a method judges params");
3317 }
3318
3319 #[test]
3320 fn a2a_from_body_refuses_what_is_not_json() {
3321 let answer = refusal("{not json");
3322 assert_eq!(answer["id"], Value::Null);
3323 assert_eq!(answer["error"]["code"], -32700);
3324 assert_eq!(answer["error"]["message"], "Invalid JSON payload");
3325 }
3326
3327 #[test]
3328 fn a2a_from_body_refuses_what_is_not_a_json_rpc_request() {
3329 for (body, id) in [
3330 (
3331 r#"{"jsonrpc":"1.0","id":1,"method":"GetTask"}"#,
3332 Value::from(1),
3333 ),
3334 (r#"{"id":1,"method":"GetTask"}"#, Value::from(1)),
3335 (
3336 r#"{"jsonrpc":2.0,"id":1,"method":"GetTask"}"#,
3337 Value::from(1),
3338 ),
3339 (
3340 r#"[{"jsonrpc":"2.0","id":1,"method":"GetTask"}]"#,
3341 Value::Null,
3342 ),
3343 (r#""GetTask""#, Value::Null),
3344 (r#"{"jsonrpc":"2.0","id":"a","method":7}"#, Value::from("a")),
3345 (r#"{"jsonrpc":"2.0","id":"a"}"#, Value::from("a")),
3346 (
3347 r#"{"jsonrpc":"2.0","id":{"a":1},"method":"GetTask"}"#,
3348 Value::Null,
3349 ),
3350 (
3351 r#"{"jsonrpc":"2.0","id":null,"method":"GetTask"}"#,
3352 Value::Null,
3353 ),
3354 (
3355 r#"{"jsonrpc":"2.0","id":1.5,"method":"GetTask"}"#,
3356 Value::Null,
3357 ),
3358 (r#"{"jsonrpc":"2.0","method":"GetTask"}"#, Value::Null),
3359 ] {
3360 let answer = refusal(body);
3361 assert_eq!(answer["error"]["code"], -32600, "{body}");
3362 assert_eq!(
3363 answer["error"]["message"], "Request payload validation error",
3364 "{body}"
3365 );
3366 assert_eq!(answer["id"], id, "{body}");
3367 }
3368 }
3369
3370 fn versions(values: &[&str]) -> Vec<String> {
3371 values.iter().map(|value| value.to_string()).collect()
3372 }
3373
3374 #[test]
3375 fn a2a_version_accepts_1_0_with_or_without_a_patch() {
3376 for value in ["1.0", " 1.0 ", "1.0.0", "1.0.1", "1.0.17"] {
3377 assert!(accepts_a2a_version(&versions(&[value])), "{value:?}");
3378 }
3379 }
3380
3381 #[test]
3382 fn a2a_version_refuses_every_other_value_and_more_than_one_line() {
3383 for values in [
3384 &[][..],
3385 &[""],
3386 &["0.3"],
3387 &["0.3.0"],
3388 &["2.0"],
3389 &["1"],
3390 &["1.1"],
3391 &["1.0x"],
3392 &["1.0."],
3393 &["1.0.a"],
3394 &["1.00"],
3395 &["v1.0"],
3396 &["1.0", "1.0"],
3397 ] {
3398 assert!(!accepts_a2a_version(&versions(values)), "{values:?}");
3399 }
3400 }
3401
3402 #[test]
3403 fn an_extension_is_activated_by_an_exact_entry_on_any_line() {
3404 const URI: &str =
3405 "https://docs.murmur.nexus/reference/streaming-protocol/#murmur-stream-v1";
3406 let other = "https://example.com/x/v1";
3407 for values in [
3408 vec![URI.to_string()],
3409 vec![format!(" {URI} ")],
3410 vec![format!("{other}, {URI}")],
3411 vec![format!("{other},,{URI},")],
3412 vec![other.to_string(), URI.to_string()],
3413 ] {
3414 assert!(activates_extension(&values, URI), "{values:?}");
3415 }
3416 for values in [
3417 vec![],
3418 vec![String::new()],
3419 vec![other.to_string()],
3420 vec![format!("{URI}/")],
3421 vec![URI.to_uppercase()],
3422 vec![format!("{URI} {other}")],
3423 ] {
3424 assert!(!activates_extension(&values, URI), "{values:?}");
3425 }
3426 }
3427
3428 #[test]
3429 fn a2a_version_refusal_names_what_was_sent() {
3430 let answer = wire(&version_not_supported(Value::from(4), &versions(&[])));
3431 assert_eq!(answer["id"], 4);
3432 assert_eq!(answer["error"]["code"], -32009);
3433 let info = &answer["error"]["data"][0];
3434 assert_eq!(info["reason"], "VERSION_NOT_SUPPORTED");
3435 assert_eq!(info["domain"], "a2a-protocol.org");
3436 assert_eq!(
3437 info["metadata"],
3438 serde_json::json!({"requestedVersion": "", "supportedVersions": "1.0"})
3439 );
3440
3441 let answer = wire(&version_not_supported(Value::Null, &versions(&[" 0.3 "])));
3442 assert_eq!(
3443 answer["error"]["data"][0]["metadata"]["requestedVersion"],
3444 "0.3"
3445 );
3446 let answer = wire(&version_not_supported(
3447 Value::Null,
3448 &versions(&["1.0", "1.0"]),
3449 ));
3450 assert_eq!(
3451 answer["error"]["data"][0]["metadata"]["requestedVersion"],
3452 "1.0, 1.0"
3453 );
3454 }
3455
3456 #[test]
3459 fn a_part_serializes_its_one_content_field_and_reads_back() {
3460 for (part, wire) in [
3461 (
3462 Part::text("hi"),
3463 serde_json::json!({"text": "hi", "mediaType": "text/plain"}),
3464 ),
3465 (
3466 Part::data(serde_json::json!({"a": [1]})),
3467 serde_json::json!({"data": {"a": [1]}, "mediaType": "application/json"}),
3468 ),
3469 (
3470 Part::data(Value::Null),
3471 serde_json::json!({"data": null, "mediaType": "application/json"}),
3472 ),
3473 ] {
3474 assert_eq!(serde_json::to_value(&part).unwrap(), wire);
3475 assert_eq!(serde_json::from_value::<Part>(wire).unwrap(), part);
3476 }
3477 for refused in [
3478 serde_json::json!({}),
3479 serde_json::json!({"text": "a", "data": 1}),
3480 serde_json::json!({"kind": "file", "file": {"uri": "x"}}),
3481 serde_json::json!({"text": 7}),
3482 serde_json::json!("text"),
3483 ] {
3484 assert!(
3485 serde_json::from_value::<Part>(refused.clone()).is_err(),
3486 "{refused}"
3487 );
3488 }
3489 }
3490
3491 fn params(message: Value) -> Value {
3492 serde_json::json!({ "message": message })
3493 }
3494
3495 fn user(parts: Value) -> Value {
3496 serde_json::json!({"messageId": "msg_1", "role": "ROLE_USER", "parts": parts})
3497 }
3498
3499 fn invalid_params(params: &Value) -> String {
3500 match read_send_params(params) {
3501 Err(MessageRefusal::InvalidParams(message)) => message,
3502 other => panic!("expected -32602 for {params}, got {other:?}"),
3503 }
3504 }
3505
3506 #[test]
3507 fn read_send_params_reads_a_v1_message_in_full() {
3508 let message = read_send_params(¶ms(serde_json::json!({
3509 "messageId": "msg_1",
3510 "contextId": "ctx_1",
3511 "taskId": "tsk_1",
3512 "role": "ROLE_USER",
3513 "referenceTaskIds": ["tsk_a", "tsk_b", "tsk_a"],
3514 "parts": [{"text": "a"}, {"data": null, "filename": "f.json"}],
3515 "metadata": {"ignored": true},
3516 "extensions": ["https://example.com/x"],
3517 "unknown": 1,
3518 })))
3519 .unwrap();
3520 assert_eq!(message.message_id, "msg_1");
3521 assert_eq!(message.context_id.as_deref(), Some("ctx_1"));
3522 assert_eq!(message.task_id.as_deref(), Some("tsk_1"));
3523 assert_eq!(message.reference_task_ids, ["tsk_a", "tsk_b"]);
3524 assert_eq!(message.part_kinds(), ["text", "data"]);
3525 assert_eq!(message.parts[1].filename.as_deref(), Some("f.json"));
3526
3527 let message = read_send_params(¶ms(serde_json::json!({
3528 "messageId": "msg_1", "role": "ROLE_USER", "taskId": "", "parts": [{"text": "a"}],
3529 })))
3530 .unwrap();
3531 assert_eq!(message.task_id, None, "an empty taskId names no task");
3532 }
3533
3534 #[test]
3537 fn read_send_params_refuses_structure_in_order() {
3538 let text = serde_json::json!([{"text": "a"}]);
3539 for (params, expected) in [
3540 (serde_json::json!({}), "params.message"),
3541 (user(text.clone()), "params.message"),
3542 (serde_json::json!({"message": "hi"}), "params.message"),
3543 (
3544 params(serde_json::json!({"role": "ROLE_USER", "parts": text})),
3545 "messageId",
3546 ),
3547 (
3548 params(serde_json::json!({"messageId": "", "role": "ROLE_USER", "parts": text})),
3549 "messageId",
3550 ),
3551 (
3552 params(serde_json::json!({"messageId": "m", "role": "user", "parts": []})),
3553 "ROLE_USER",
3554 ),
3555 (
3556 params(serde_json::json!({"messageId": "m", "role": "ROLE_AGENT", "parts": text})),
3557 "ROLE_USER",
3558 ),
3559 (
3560 params(serde_json::json!({"messageId": "m", "role": 1, "parts": text})),
3561 "ROLE_USER",
3562 ),
3563 (
3564 params(serde_json::json!({"messageId": "m", "parts": text})),
3565 "ROLE_USER",
3566 ),
3567 (params(user(serde_json::json!([]))), "non-empty array"),
3568 (
3569 params(user(serde_json::json!({"text": "a"}))),
3570 "non-empty array",
3571 ),
3572 (
3573 params(user(serde_json::json!([{"text": "a"}, {}]))),
3574 "message.parts[1] sets none",
3575 ),
3576 (
3577 params(user(serde_json::json!([{"text": "a", "data": 1}]))),
3578 "message.parts[0] sets text and data",
3579 ),
3580 (
3581 params(user(
3582 serde_json::json!([{"kind": "file", "file": {"uri": "x"}}]),
3583 )),
3584 "message.parts[0] sets none",
3585 ),
3586 (
3587 params(user(serde_json::json!([{"url": 7}, {}]))),
3588 "message.parts[1] sets none",
3589 ),
3590 (
3591 params(user(serde_json::json!([{"text": 7}]))),
3592 "message.parts[0].text is not a string",
3593 ),
3594 (
3595 params(user(serde_json::json!([{"data": 1, "mediaType": 2}]))),
3596 "message.parts[0].mediaType",
3597 ),
3598 (
3599 params(serde_json::json!({"messageId": "m", "role": "ROLE_USER",
3600 "parts": [{"url": "u"}], "taskId": 7})),
3601 "message.taskId",
3602 ),
3603 (
3604 params(serde_json::json!({"messageId": "m", "role": "ROLE_USER",
3605 "parts": text, "contextId": ["c"]})),
3606 "message.contextId",
3607 ),
3608 (
3609 params(serde_json::json!({"messageId": "m", "role": "ROLE_USER",
3610 "parts": [{"raw": "aGk="}], "referenceTaskIds": [""]})),
3611 "referenceTaskIds",
3612 ),
3613 (
3614 params(serde_json::json!({"messageId": "m", "role": "ROLE_USER",
3615 "parts": text, "referenceTaskIds": [7]})),
3616 "referenceTaskIds",
3617 ),
3618 (
3619 params(serde_json::json!({"messageId": "m", "role": "ROLE_USER",
3620 "parts": text, "referenceTaskIds": "tsk_a"})),
3621 "referenceTaskIds",
3622 ),
3623 ] {
3624 let message = invalid_params(¶ms);
3625 assert!(message.contains(expected), "{params}: {message}");
3626 }
3627 }
3628
3629 #[test]
3630 fn read_send_params_caps_distinct_references() {
3631 let ids = |n: usize| -> Vec<String> { (0..n).map(|i| format!("tsk_{i}")).collect() };
3632 let with = |ids: Vec<String>| {
3633 params(serde_json::json!({"messageId": "m", "role": "ROLE_USER",
3634 "parts": [{"text": "a"}], "referenceTaskIds": ids}))
3635 };
3636 let mut sixteen_twice = ids(MAX_REFERENCE_TASK_IDS);
3637 sixteen_twice.extend(ids(MAX_REFERENCE_TASK_IDS));
3638 assert_eq!(
3639 read_send_params(&with(sixteen_twice))
3640 .unwrap()
3641 .reference_task_ids,
3642 ids(MAX_REFERENCE_TASK_IDS)
3643 );
3644 let message = invalid_params(&with(ids(MAX_REFERENCE_TASK_IDS + 1)));
3645 assert!(message.contains("names 17 tasks"), "{message}");
3646 }
3647
3648 #[test]
3651 fn read_send_params_refuses_a_push_notification_config() {
3652 let message = user(serde_json::json!([{"text": "hi"}]));
3653 let asking = serde_json::json!({"message": message,
3654 "configuration": {"taskPushNotificationConfig": {"url": "https://example.com/hook"}}});
3655 assert_eq!(
3656 read_send_params(&asking),
3657 Err(MessageRefusal::PushNotificationNotSupported)
3658 );
3659 let answer = serde_json::to_value(
3660 MessageRefusal::PushNotificationNotSupported.into_response(Value::from(1)),
3661 )
3662 .unwrap();
3663 assert_eq!(answer["error"]["code"], -32003);
3664 assert_eq!(
3665 answer["error"]["message"],
3666 "push notifications are not supported by this agent"
3667 );
3668 assert_eq!(
3669 answer["error"]["data"][0],
3670 serde_json::json!({"@type": "type.googleapis.com/google.rpc.ErrorInfo",
3671 "reason": "PUSH_NOTIFICATION_NOT_SUPPORTED", "domain": "a2a-protocol.org",
3672 "metadata": {}})
3673 );
3674 for configuration in [
3675 serde_json::json!({"taskPushNotificationConfig": null}),
3676 serde_json::json!({"acceptedOutputModes": ["text/plain"], "historyLength": 2}),
3677 serde_json::json!("ignored"),
3678 ] {
3679 let params = serde_json::json!({"message": message, "configuration": configuration});
3680 assert!(read_send_params(¶ms).is_ok(), "{params}");
3681 }
3682 }
3683
3684 fn with_metadata(metadata: Value, task_id: Option<&str>) -> Value {
3686 let mut message = user(serde_json::json!([{"text": "a"}]));
3687 message["metadata"] = metadata;
3688 if let Some(task_id) = task_id {
3689 message["taskId"] = Value::from(task_id);
3690 }
3691 params(message)
3692 }
3693
3694 fn door(door: Value) -> Value {
3696 serde_json::json!({ DOOR_EXTENSION_URI: door })
3697 }
3698
3699 #[test]
3700 fn read_send_params_reads_forget_session_true() {
3701 let message = read_send_params(&with_metadata(
3702 door(serde_json::json!({"forgetSession": true})),
3703 None,
3704 ))
3705 .unwrap();
3706 assert!(message.forget_session);
3707 assert!(message.door_extension);
3708 }
3709
3710 #[test]
3711 fn read_send_params_reads_every_other_metadata_as_no_forget() {
3712 for (metadata, door_extension) in [
3713 (door(serde_json::json!({"forgetSession": false})), true),
3714 (door(serde_json::json!({})), true),
3715 (
3716 door(serde_json::json!({"other": 1, "forgetSession": false})),
3717 true,
3718 ),
3719 (serde_json::json!({}), false),
3720 (
3721 serde_json::json!({"https://example.com/x": {"forgetSession": true}}),
3722 false,
3723 ),
3724 (serde_json::json!({"forgetSession": true}), false),
3725 (serde_json::json!("not an object"), false),
3726 (
3727 serde_json::json!([door(serde_json::json!({"forgetSession": true}))]),
3728 false,
3729 ),
3730 (Value::Null, false),
3731 ] {
3732 let message = read_send_params(&with_metadata(metadata.clone(), None)).unwrap();
3733 assert!(!message.forget_session, "{metadata}");
3734 assert_eq!(message.door_extension, door_extension, "{metadata}");
3735 }
3736 let message = read_send_params(¶ms(user(serde_json::json!([{"text": "a"}])))).unwrap();
3737 assert!(
3738 !message.forget_session && !message.door_extension,
3739 "no metadata at all"
3740 );
3741 }
3742
3743 #[test]
3744 fn read_send_params_ignores_other_keys_beside_forget_session() {
3745 let message = read_send_params(&with_metadata(
3746 door(serde_json::json!({"forgetSession": true, "later": {"x": 1}})),
3747 None,
3748 ))
3749 .unwrap();
3750 assert!(message.forget_session);
3751 }
3752
3753 #[test]
3754 fn read_send_params_refuses_a_door_extension_value_that_is_not_an_object() {
3755 for value in [
3756 serde_json::json!(true),
3757 serde_json::json!("forget"),
3758 serde_json::json!([]),
3759 ] {
3760 assert_eq!(
3761 invalid_params(&with_metadata(door(value), None)),
3762 format!("message.metadata[\"{DOOR_EXTENSION_URI}\"] must be an object")
3763 );
3764 }
3765 }
3766
3767 #[test]
3768 fn read_send_params_refuses_a_forget_session_that_is_not_a_boolean() {
3769 for value in [serde_json::json!("true"), serde_json::json!(1), Value::Null] {
3770 assert_eq!(
3771 invalid_params(&with_metadata(
3772 door(serde_json::json!({"forgetSession": value})),
3773 None
3774 )),
3775 format!(
3776 "message.metadata[\"{DOOR_EXTENSION_URI}\"].forgetSession must be a boolean"
3777 )
3778 );
3779 }
3780 }
3781
3782 #[test]
3783 fn read_send_params_refuses_a_forget_on_a_message_that_continues_a_task() {
3784 assert_eq!(
3785 invalid_params(&with_metadata(
3786 door(serde_json::json!({"forgetSession": true})),
3787 Some("tsk_1")
3788 )),
3789 "forgetSession starts a new conversation, so it goes on a message that starts a task, \
3790 not on one that continues task tsk_1"
3791 );
3792 let message = read_send_params(&with_metadata(
3794 door(serde_json::json!({"forgetSession": false})),
3795 Some("tsk_1"),
3796 ))
3797 .unwrap();
3798 assert_eq!(message.task_id.as_deref(), Some("tsk_1"));
3799 assert!(!message.forget_session);
3800 }
3801
3802 #[test]
3805 fn read_send_params_checks_the_forget_field_in_order() {
3806 let mut message = with_metadata(door(Value::from(1)), None);
3808 message["message"]["referenceTaskIds"] = Value::from("tsk_a");
3809 assert!(invalid_params(&message).contains("referenceTaskIds"));
3810 let message = with_metadata(door(Value::from(1)), Some("tsk_1"));
3812 assert!(invalid_params(&message).ends_with("must be an object"));
3813 let message = with_metadata(
3815 door(serde_json::json!({"forgetSession": "yes"})),
3816 Some("tsk_1"),
3817 );
3818 assert!(invalid_params(&message).ends_with("must be a boolean"));
3819 let mut message = with_metadata(door(serde_json::json!({"forgetSession": 1})), None);
3821 message["message"]["parts"] = serde_json::json!([{"raw": "aGk="}]);
3822 assert!(invalid_params(&message).ends_with("must be a boolean"));
3823 let mut message = with_metadata(door(serde_json::json!({"forgetSession": true})), None);
3825 message["message"]["parts"] = serde_json::json!([{"raw": "aGk="}]);
3826 assert!(matches!(
3827 read_send_params(&message),
3828 Err(MessageRefusal::ContentTypeNotSupported { .. })
3829 ));
3830 let refusal = read_send_params(&with_metadata(door(Value::from(1)), None)).unwrap_err();
3832 let answer = wire(&refusal.into_response(Value::from(3)));
3833 assert_eq!(answer["error"]["code"], -32602);
3834 assert_eq!(
3835 answer["error"]["message"],
3836 format!("Invalid params: message.metadata[\"{DOOR_EXTENSION_URI}\"] must be an object")
3837 );
3838 }
3839
3840 fn configured(configuration: Value) -> Value {
3842 serde_json::json!({
3843 "message": user(serde_json::json!([{"text": "a"}])),
3844 "configuration": configuration,
3845 })
3846 }
3847
3848 #[test]
3849 fn read_send_params_reads_return_immediately_true_only_for_true() {
3850 let immediately = read_send_params(&configured(serde_json::json!({
3851 "returnImmediately": true,
3852 })))
3853 .unwrap();
3854 assert!(immediately.return_immediately);
3855 for params in [
3856 params(user(serde_json::json!([{"text": "a"}]))),
3857 configured(Value::Null),
3858 configured(serde_json::json!({})),
3859 configured(serde_json::json!({"returnImmediately": null})),
3860 configured(serde_json::json!({"returnImmediately": false})),
3861 configured(serde_json::json!({"historyLength": 2})),
3862 configured(serde_json::json!("ignored")),
3863 ] {
3864 assert!(
3865 !read_send_params(¶ms).unwrap().return_immediately,
3866 "{params}"
3867 );
3868 }
3869 }
3870
3871 #[test]
3872 fn read_send_params_refuses_a_return_immediately_that_is_not_a_boolean() {
3873 for value in [
3874 Value::from("yes"),
3875 Value::from(1),
3876 serde_json::json!({}),
3877 serde_json::json!([true]),
3878 ] {
3879 let params = configured(serde_json::json!({ "returnImmediately": value }));
3880 assert_eq!(
3881 invalid_params(¶ms),
3882 "configuration.returnImmediately must be a boolean",
3883 "{params}"
3884 );
3885 let answer = wire(
3886 &read_send_params(¶ms)
3887 .unwrap_err()
3888 .into_response(1.into()),
3889 );
3890 assert_eq!(answer["error"]["code"], -32602);
3891 assert_eq!(
3892 answer["error"]["message"],
3893 "Invalid params: configuration.returnImmediately must be a boolean"
3894 );
3895 }
3896 }
3897
3898 #[test]
3901 fn read_send_params_checks_return_immediately_in_order() {
3902 let mut message = with_metadata(door(serde_json::json!({"forgetSession": 1})), None);
3903 message["configuration"] = serde_json::json!({"returnImmediately": "yes"});
3904 assert!(invalid_params(&message).contains("forgetSession"));
3905
3906 let mut message = configured(serde_json::json!({
3907 "returnImmediately": "yes",
3908 "taskPushNotificationConfig": {"url": "https://example.com/hook"},
3909 }));
3910 message["message"]["parts"] = serde_json::json!([{"raw": "aGk="}]);
3911 assert!(invalid_params(&message).contains("returnImmediately"));
3912
3913 message["configuration"]["returnImmediately"] = Value::Bool(true);
3914 assert!(matches!(
3915 read_send_params(&message),
3916 Err(MessageRefusal::ContentTypeNotSupported { .. })
3917 ));
3918 }
3919
3920 #[test]
3922 fn read_send_params_refuses_the_first_file_part_with_content_type_not_supported() {
3923 assert_eq!(
3924 read_send_params(¶ms(user(serde_json::json!([
3925 {"text": "a"},
3926 {"url": "https://example.com/x.pdf", "mediaType": "application/pdf"},
3927 {"raw": "aGk="},
3928 ])))),
3929 Err(MessageRefusal::ContentTypeNotSupported {
3930 part_index: 1,
3931 kind: "url",
3932 media_type: Some("application/pdf".to_string()),
3933 })
3934 );
3935 let refusal =
3936 read_send_params(¶ms(user(serde_json::json!([{"raw": "aGk="}])))).unwrap_err();
3937 let answer = wire(&refusal.into_response(Value::from(3)));
3938 assert_eq!(answer["error"]["code"], -32005);
3939 let info = &answer["error"]["data"][0];
3940 assert_eq!(info["reason"], "CONTENT_TYPE_NOT_SUPPORTED");
3941 assert_eq!(info["metadata"], serde_json::json!({"partIndex": "0"}));
3942 invalid_params(¶ms(user(
3944 serde_json::json!([{"raw": "aGk="}, {"text": 1}]),
3945 )));
3946 }
3947
3948 fn rendered(parts: Value) -> String {
3949 read_send_params(¶ms(user(parts))).unwrap().agent_text()
3950 }
3951
3952 #[test]
3953 fn agent_text_of_text_parts_is_the_text_joined_by_newlines() {
3954 assert_eq!(
3955 rendered(
3956 serde_json::json!([{"text": "one"}, {"text": "two", "mediaType": "text/markdown"}])
3957 ),
3958 "one\ntwo"
3959 );
3960 }
3961
3962 #[test]
3963 fn agent_text_fences_a_data_part_beyond_its_longest_backtick_run() {
3964 assert_eq!(
3965 rendered(serde_json::json!([{"text": "summarise"}, {"data": {"rows": [1, 2]}}])),
3966 "summarise\n```data\n{\n \"rows\": [\n 1,\n 2\n ]\n}\n```"
3967 );
3968 let three =
3969 rendered(serde_json::json!([{"data": "a ``` b", "mediaType": "application/json"}]));
3970 assert_eq!(
3971 three,
3972 "````data media-type=application/json\n\"a ``` b\"\n````"
3973 );
3974 let five = rendered(serde_json::json!([{"data": {"x": "`````"}}]));
3975 assert!(five.starts_with("``````data\n"), "{five}");
3976 assert!(five.ends_with("\n``````"), "{five}");
3977 assert_eq!(
3978 rendered(serde_json::json!([{"data": null}])),
3979 "```data\nnull\n```"
3980 );
3981 }
3982
3983 #[test]
3984 fn agent_text_sanitises_the_fence_label() {
3985 assert_eq!(
3986 rendered(serde_json::json!([{"data": 1, "mediaType": "a b=`c",
3987 "filename": "my\tfile.json"}])),
3988 "```data media-type=a_b__c filename=my_file.json\n1\n```"
3989 );
3990 }
3991
3992 #[test]
3993 fn a_reference_block_names_each_task_as_it_stands() {
3994 let mut r = TaskRegistry::new(8, TaskAcceptance::Queue);
3995 for id in ["tsk_a", "tsk_b", "tsk_c", "tsk_e"] {
3996 r.enqueue(id, "ctx_001");
3997 }
3998 r.start_task("tsk_a".to_string(), "ctx_001".to_string(), TaskLane::Bg);
3999 r.finish_task(TaskState::Completed);
4000 r.record_ending("tsk_a", "done", Some("four ```"), false, &[], &[]);
4001 r.start_task("tsk_b".to_string(), "ctx_001".to_string(), TaskLane::Bg);
4002 r.finish_task(TaskState::Failed);
4003 r.record_ending("tsk_b", "the driver\nfailed", None, false, &[], &[]);
4004 r.start_task("tsk_c".to_string(), "ctx_001".to_string(), TaskLane::Bg);
4005 r.record_owner("tsk_e", "member:reviewer");
4006 let ids: Vec<String> = ["tsk_a", "tsk_b", "tsk_c", "tsk_d", "tsk_e"]
4007 .map(str::to_string)
4008 .to_vec();
4009
4010 assert_eq!(
4011 r.reference_block(&ids, |_| true).unwrap(),
4012 "Referenced tasks:\n\
4013 - tsk_a: completed\n````response\nfour ```\n````\n\
4014 - tsk_b: failed: the driver failed\n\
4015 - tsk_c: working\n\
4016 - tsk_d: not a task this capsule holds\n\
4017 - tsk_e: submitted"
4018 );
4019 let member = |task_id: &str| r.owner(task_id) == Some("member:planner");
4020 let block = r.reference_block(&ids[4..], member).unwrap();
4021 assert_eq!(
4022 block,
4023 "Referenced tasks:\n- tsk_e: not a task this capsule holds"
4024 );
4025 assert_eq!(r.reference_block(&[], |_| true), None);
4026 }
4027
4028 #[test]
4029 fn user_message_is_a_v1_message_from_the_user() {
4030 assert_eq!(
4031 user_message("msg_1", Some("ctx_1"), "hello"),
4032 serde_json::json!({
4033 "messageId": "msg_1",
4034 "contextId": "ctx_1",
4035 "role": "ROLE_USER",
4036 "parts": [{"text": "hello", "mediaType": "text/plain"}],
4037 })
4038 );
4039 assert!(user_message("msg_1", None, "hello")
4040 .get("contextId")
4041 .is_none());
4042 assert!(read_send_params(¶ms(user_message("msg_1", None, "hello"))).is_ok());
4043 }
4044
4045 #[test]
4046 fn send_message_params_answer_at_acceptance() {
4047 let message = user_message("msg_1", None, "hello");
4048 let params = send_message_params(message.clone());
4049 assert_eq!(
4050 params,
4051 serde_json::json!({
4052 "message": message,
4053 "configuration": {"returnImmediately": true},
4054 })
4055 );
4056 assert!(read_send_params(¶ms).unwrap().return_immediately);
4057 }
4058
4059 #[test]
4060 fn send_message_result_is_exactly_one_member_of_the_oneof() {
4061 let task = serde_json::json!({"id": "tsk_1", "contextId": "c",
4062 "status": {"state": "TASK_STATE_SUBMITTED"}});
4063 assert!(matches!(
4064 SendMessageResult::from_result(&serde_json::json!({ "task": task })),
4065 Ok(SendMessageResult::Task(task)) if task.id == "tsk_1"
4066 ));
4067 assert!(matches!(
4068 SendMessageResult::from_result(&serde_json::json!({"message": {"messageId": "m"}})),
4069 Ok(SendMessageResult::Message(_))
4070 ));
4071 for refused in [
4072 serde_json::json!({}),
4073 serde_json::json!({"task": task, "message": {}}),
4074 task.clone(),
4075 serde_json::json!({"task": {"id": "tsk_1", "contextId": "c",
4076 "status": {"state": "submitted"}}}),
4077 ] {
4078 assert!(
4079 SendMessageResult::from_result(&refused).is_err(),
4080 "{refused}"
4081 );
4082 }
4083 }
4084}