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 member_request_id: Option<String>,
2201 pub caller: Option<crate::identity::TaskCaller>,
2205 pub reference_task_ids: Vec<String>,
2208 pub part_kinds: Vec<&'static str>,
2211 pub images: Vec<(usize, crate::images::CheckedImage)>,
2214}
2215
2216pub(crate) const SOURCE_A2A: &str = "a2a";
2218
2219pub(crate) const SOURCE_TASK_MD: &str = "task_md";
2223
2224pub(crate) const SOURCE_DETACHED_SHELL: &str = "detached_shell";
2226
2227pub(crate) const SOURCE_DETACHED_LOST: &str = "detached_lost";
2231
2232#[cfg(test)]
2235mod tests {
2236 use super::*;
2237
2238 fn make_registry() -> TaskRegistry {
2239 TaskRegistry::new(1, TaskAcceptance::Single)
2240 }
2241
2242 fn running_registry(task_id: &str) -> TaskRegistry {
2243 let mut r = make_registry();
2244 r.enqueue(task_id, "ctx_001");
2245 r.start_task(task_id.to_string(), "ctx_001".to_string(), TaskLane::Bg);
2246 r
2247 }
2248
2249 #[test]
2250 fn set_input_required_transitions_state() {
2251 let mut r = running_registry("tsk_001");
2252 let (tx, _rx) = oneshot::channel();
2253 assert!(r
2254 .set_input_required("tsk_001", "which branch?".into(), tx)
2255 .is_ok());
2256 let task = r.get_task("tsk_001").unwrap();
2257 assert_eq!(task.status.state, TaskState::InputRequired);
2258 let artifacts = task.artifacts.unwrap();
2259 assert_eq!(artifacts[0].artifact_id, "prompt");
2260 assert_eq!(artifacts[0].first_text(), Some("which branch?"));
2261 }
2262
2263 #[test]
2264 fn set_input_required_fails_for_wrong_task() {
2265 let mut r = running_registry("tsk_001");
2266 let (tx, _rx) = oneshot::channel();
2267 assert!(r
2268 .set_input_required("tsk_other", "prompt".into(), tx)
2269 .is_err());
2270 }
2271
2272 #[test]
2273 fn deliver_input_transitions_back_to_working() {
2274 let mut r = running_registry("tsk_001");
2275 let (tx, mut rx) = oneshot::channel();
2276 r.set_input_required("tsk_001", "which branch?".into(), tx)
2277 .unwrap();
2278 r.deliver_input("tsk_001", "main".to_string()).unwrap();
2279 assert_eq!(rx.try_recv().unwrap(), "main");
2281 let task = r.get_task("tsk_001").unwrap();
2282 assert_eq!(task.status.state, TaskState::Working);
2283 assert!(task.artifacts.is_none());
2284 }
2285
2286 #[test]
2287 fn deliver_input_fails_when_not_input_required() {
2288 let mut r = running_registry("tsk_001");
2289 assert!(r.deliver_input("tsk_001", "text".into()).is_err());
2290 }
2291
2292 #[test]
2293 fn an_input_required_task_carries_its_prompt_and_nothing_else() {
2294 let mut r = running_registry("tsk_1");
2295 let (tx, _rx) = oneshot::channel();
2296 r.set_input_required("tsk_1", "Which file?".to_string(), tx)
2297 .unwrap();
2298 let task = r.get_task("tsk_1").unwrap();
2299 let artifacts = task.artifacts.unwrap();
2300 assert_eq!(artifacts.len(), 1);
2301 assert_eq!(
2302 serde_json::to_value(&artifacts[0]).unwrap(),
2303 serde_json::json!({"artifactId": "prompt", "name": "prompt",
2304 "parts": [{"text": "Which file?", "mediaType": "text/plain"}]})
2305 );
2306 assert!(task.status.message.is_none());
2307 }
2308
2309 #[test]
2311 fn a_working_task_carries_no_artifact_and_no_message() {
2312 let r = running_registry("tsk_1");
2313 let task = serde_json::to_value(r.get_task("tsk_1").unwrap()).unwrap();
2314 assert_bare_status(&task["status"], "TASK_STATE_WORKING");
2315 assert_eq!(
2316 task,
2317 serde_json::json!({"id": "tsk_1", "contextId": "ctx_001", "status": {
2318 "state": "TASK_STATE_WORKING",
2319 "timestamp": timestamp_of(&r, "tsk_1"),
2320 }})
2321 );
2322 }
2323
2324 #[test]
2327 fn a_completed_task_carries_its_response_artifact_and_status_message() {
2328 let mut r = running_registry("tsk_1");
2329 r.finish_task(TaskState::Completed);
2330 r.record_ending("tsk_1", "done", Some("the answer is 4"), false, &[], &[]);
2331 let task = serde_json::to_value(r.get_task("tsk_1").unwrap()).unwrap();
2332 assert_eq!(
2333 task,
2334 serde_json::json!({
2335 "id": "tsk_1",
2336 "contextId": "ctx_001",
2337 "status": {
2338 "state": "TASK_STATE_COMPLETED",
2339 "message": {
2340 "messageId": "msg_tsk_1_status",
2341 "contextId": "ctx_001",
2342 "taskId": "tsk_1",
2343 "role": "ROLE_AGENT",
2344 "parts": [{"text": "done", "mediaType": "text/plain"}],
2345 },
2346 "timestamp": status_timestamp(r.status_times["tsk_1"]),
2347 },
2348 "artifacts": [{
2349 "artifactId": "response",
2350 "name": "response",
2351 "parts": [{"text": "the answer is 4", "mediaType": "text/plain"}],
2352 }],
2353 })
2354 );
2355 }
2356
2357 #[test]
2361 fn a_task_ending_with_commands_running_names_them_in_its_metadata() {
2362 let long = "x".repeat(250);
2363 let running = [
2364 RunningCommand {
2365 work_id: "wrk_0a".to_string(),
2366 binary: "/usr/bin/bash".to_string(),
2367 command: "sleep 300".to_string(),
2368 running_ms: 12_000,
2369 },
2370 RunningCommand {
2371 work_id: "wrk_0b".to_string(),
2372 binary: "/usr/bin/bash".to_string(),
2373 command: long.clone(),
2374 running_ms: 3_000,
2375 },
2376 ];
2377 let mut r = running_registry("tsk_1");
2378 r.finish_task(TaskState::Completed);
2379 r.record_ending("tsk_1", "done", Some("4"), false, &[], &running);
2380 let task = serde_json::to_value(r.get_task("tsk_1").unwrap()).unwrap();
2381 assert_eq!(
2382 task["metadata"],
2383 serde_json::json!({"murmur": {"runningCommands": [
2384 {"workId": "wrk_0a", "binary": "/usr/bin/bash", "command": "sleep 300",
2385 "runningMs": 12_000},
2386 {"workId": "wrk_0b", "binary": "/usr/bin/bash",
2387 "command": format!("{}…", &long[..200]), "runningMs": 3_000},
2388 ]}})
2389 );
2390
2391 let mut r = running_registry("tsk_2");
2392 r.finish_task(TaskState::Completed);
2393 r.record_ending("tsk_2", "done", Some("4"), false, &[], &[]);
2394 let task = serde_json::to_value(r.get_task("tsk_2").unwrap()).unwrap();
2395 assert!(task.get("metadata").is_none(), "{task}");
2396 }
2397
2398 #[test]
2403 fn a_no_answer_ending_carries_its_metadata_and_no_other_ending_does() {
2404 let below = [("q".to_string(), "timed_out".to_string())];
2405 let mut r = running_registry("tsk_1");
2406 r.finish_task(TaskState::Failed);
2407 r.record_ending("tsk_1", "q gave no answer", None, true, &below, &[]);
2408 let task = serde_json::to_value(r.get_task("tsk_1").unwrap()).unwrap();
2409 assert_eq!(task["status"]["state"], "TASK_STATE_FAILED");
2410 assert_eq!(
2411 task["status"]["message"]["parts"][0]["text"],
2412 "q gave no answer"
2413 );
2414 assert_eq!(
2415 task["metadata"],
2416 serde_json::json!({"murmur": {
2417 "noAnswer": true,
2418 "noAnswerBelow": [{"member": "q", "status": "timed_out"}],
2419 }})
2420 );
2421
2422 let mut r = running_registry("tsk_2");
2423 r.finish_task(TaskState::Completed);
2424 r.record_ending(
2425 "tsk_2",
2426 "task completed",
2427 Some("none came"),
2428 false,
2429 &below,
2430 &[],
2431 );
2432 let task = serde_json::to_value(r.get_task("tsk_2").unwrap()).unwrap();
2433 assert_eq!(
2434 task["artifacts"],
2435 serde_json::json!([{"artifactId": "response", "name": "response",
2436 "parts": [{"text": "none came", "mediaType": "text/plain"}]}])
2437 );
2438 assert_eq!(
2439 task["metadata"],
2440 serde_json::json!({"murmur": {
2441 "noAnswerBelow": [{"member": "q", "status": "timed_out"}],
2442 }})
2443 );
2444
2445 for state in [TaskState::Completed, TaskState::Failed] {
2446 let mut r = running_registry("tsk_3");
2447 r.finish_task(state);
2448 r.record_ending("tsk_3", "ended", Some("4"), false, &[], &[]);
2449 let task = serde_json::to_value(r.get_task("tsk_3").unwrap()).unwrap();
2450 assert!(task.get("metadata").is_none(), "{task}");
2451 }
2452 let r = running_registry("tsk_4");
2453 let task = serde_json::to_value(r.get_task("tsk_4").unwrap()).unwrap();
2454 assert!(task.get("metadata").is_none(), "{task}");
2455 }
2456
2457 #[test]
2459 fn a_failed_task_carries_its_status_message_and_no_response() {
2460 let mut r = running_registry("tsk_1");
2461 r.finish_task(TaskState::Failed);
2462 r.record_ending(
2463 "tsk_1",
2464 "the driver failed",
2465 Some("partial"),
2466 false,
2467 &[],
2468 &[],
2469 );
2470 let task = r.get_task("tsk_1").unwrap();
2471 assert!(task.artifacts.is_none());
2472 let message = task.status.message.unwrap();
2473 assert_eq!(message.role, Role::Agent);
2474 assert_eq!(message.text(), Some("the driver failed"));
2475 }
2476
2477 #[test]
2479 fn a_completed_task_without_a_response_carries_no_artifact() {
2480 let mut r = running_registry("tsk_1");
2481 r.finish_task(TaskState::Completed);
2482 assert!(r.get_task("tsk_1").unwrap().artifacts.is_none());
2483 r.record_ending("tsk_1", "ok", Some(""), false, &[], &[]);
2484 let task = r.get_task("tsk_1").unwrap();
2485 assert!(task.artifacts.is_none());
2486 assert_eq!(task.status.message.unwrap().text(), Some("ok"));
2487 }
2488
2489 #[test]
2490 fn an_owner_is_recorded_per_task() {
2491 let mut r = make_registry();
2492 r.enqueue("tsk_a", "ctx_001");
2493 r.record_owner("tsk_a", "member:lead");
2494 r.enqueue("tsk_b", "ctx_001");
2495 assert_eq!(r.owner("tsk_a"), Some("member:lead"));
2496 assert_eq!(r.owner("tsk_b"), None);
2497 r.withdraw("tsk_a");
2498 assert_eq!(r.owner("tsk_a"), None);
2499 assert!(r.get_task("tsk_a").is_none());
2500 assert_eq!(r.pending_count, 1);
2501 }
2502
2503 fn listed(page: &TaskPage) -> Vec<&str> {
2505 page.tasks.iter().map(|task| task.id.as_str()).collect()
2506 }
2507
2508 fn every_task() -> TaskFilter {
2509 TaskFilter {
2510 context_id: None,
2511 state: StateFilter::Any,
2512 status_after: None,
2513 }
2514 }
2515
2516 #[test]
2518 fn list_tasks_orders_by_the_last_change_newest_first() {
2519 let mut r = TaskRegistry::new(8, TaskAcceptance::Queue);
2520 for id in ["tsk_a", "tsk_b", "tsk_c"] {
2521 r.enqueue(id, "ctx_001");
2522 }
2523 let all = |r: &TaskRegistry| -> Vec<String> {
2524 listed(&r.list_tasks(&every_task(), None, 10, |_| true))
2525 .into_iter()
2526 .map(str::to_string)
2527 .collect()
2528 };
2529 assert_eq!(all(&r), ["tsk_c", "tsk_b", "tsk_a"]);
2530 r.start_task("tsk_a".to_string(), "ctx_001".to_string(), TaskLane::User);
2531 assert_eq!(all(&r), ["tsk_a", "tsk_c", "tsk_b"]);
2532 r.request_cancel("tsk_b");
2533 assert_eq!(all(&r), ["tsk_b", "tsk_a", "tsk_c"]);
2534 let (tx, _rx) = oneshot::channel();
2535 r.set_input_required("tsk_a", "which?".to_string(), tx)
2536 .unwrap();
2537 assert_eq!(all(&r), ["tsk_a", "tsk_b", "tsk_c"]);
2538 r.deliver_input("tsk_a", "left".to_string()).unwrap();
2539 r.finish_task(TaskState::Completed);
2540 r.record_ending("tsk_c", "", None, false, &[], &[]);
2541 assert_eq!(all(&r), ["tsk_c", "tsk_a", "tsk_b"]);
2542 r.record_ending("tsk_a", "done", Some("four"), false, &[], &[]);
2543 assert_eq!(all(&r), ["tsk_a", "tsk_c", "tsk_b"]);
2544 r.close_to_new_work();
2545 assert_eq!(all(&r), ["tsk_c", "tsk_a", "tsk_b"], "tsk_c was rejected");
2546
2547 let page = r.list_tasks(&every_task(), None, 10, |_| true);
2548 for task in &page.tasks {
2549 assert_eq!(
2550 serde_json::to_value(task).unwrap(),
2551 serde_json::to_value(r.get_task(&task.id).unwrap()).unwrap()
2552 );
2553 }
2554 }
2555
2556 #[test]
2559 fn list_tasks_walks_pages_and_returns_a_task_changed_mid_walk_once() {
2560 let mut r = TaskRegistry::new(8, TaskAcceptance::Queue);
2561 for id in ["tsk_1", "tsk_2", "tsk_3", "tsk_4", "tsk_5"] {
2562 r.enqueue(id, "ctx_001");
2563 }
2564 let first = r.list_tasks(&every_task(), None, 2, |_| true);
2565 assert_eq!(listed(&first), ["tsk_5", "tsk_4"]);
2566 assert_eq!(first.total, 5);
2567 let cursor = first.next.clone().expect("more tasks lie past the page");
2568 assert_eq!(ListCursor::decode(&cursor.encode()), Some(cursor.clone()));
2569
2570 r.request_cancel("tsk_4");
2571 r.request_cancel("tsk_2");
2572 let second = r.list_tasks(&every_task(), Some(&cursor), 2, |_| true);
2573 assert_eq!(listed(&second), ["tsk_3", "tsk_1"], "tsk_2 moved ahead");
2574 assert_eq!(second.next, None);
2575 assert_eq!(second.total, 5);
2576 let fresh = r.list_tasks(&every_task(), None, 2, |_| true);
2577 assert_eq!(listed(&fresh), ["tsk_2", "tsk_4"]);
2578
2579 let exact = r.list_tasks(&every_task(), None, 5, |_| true);
2580 assert_eq!(exact.tasks.len(), 5);
2581 assert_eq!(
2582 exact.next, None,
2583 "no task lies past a page that ends the list"
2584 );
2585 }
2586
2587 fn instant(timestamp: &str) -> DateTime<Utc> {
2589 DateTime::parse_from_rfc3339(timestamp)
2590 .unwrap()
2591 .with_timezone(&Utc)
2592 }
2593
2594 fn timestamp_of(r: &TaskRegistry, task_id: &str) -> String {
2595 r.get_task(task_id).unwrap().status.timestamp.unwrap()
2596 }
2597
2598 fn a_tick() {
2600 std::thread::sleep(std::time::Duration::from_millis(3));
2601 }
2602
2603 #[test]
2606 fn a_status_timestamp_moves_with_the_revision() {
2607 let mut r = TaskRegistry::new(8, TaskAcceptance::Queue);
2608 r.enqueue("tsk_a", "ctx_001");
2609 let submitted = timestamp_of(&r, "tsk_a");
2610 let submitted_revision = r.revisions["tsk_a"];
2611 assert_eq!(r.status_times["tsk_a"], instant(&submitted));
2612
2613 a_tick();
2614 r.record_owner("tsk_a", "alice");
2615 assert_eq!(
2616 timestamp_of(&r, "tsk_a"),
2617 submitted,
2618 "an owner is no change"
2619 );
2620 assert_eq!(r.revisions["tsk_a"], submitted_revision);
2621
2622 r.start_task("tsk_a".to_string(), "ctx_001".to_string(), TaskLane::User);
2623 let working = timestamp_of(&r, "tsk_a");
2624 assert!(r.revisions["tsk_a"] > submitted_revision);
2625 assert!(
2626 instant(&working) > instant(&submitted),
2627 "{submitted} → {working}"
2628 );
2629
2630 a_tick();
2631 r.finish_task(TaskState::Completed);
2632 let completed_revision = r.revisions["tsk_a"];
2633 let completed = timestamp_of(&r, "tsk_a");
2634 assert!(instant(&completed) > instant(&working));
2635
2636 a_tick();
2637 r.record_ending("tsk_a", "done", Some("four"), false, &[], &[]);
2638 assert!(r.revisions["tsk_a"] > completed_revision);
2639 assert!(instant(×tamp_of(&r, "tsk_a")) > instant(&completed));
2640 }
2641
2642 #[test]
2645 fn a_status_timestamp_round_trips_and_a_cutoff_equal_to_it_includes_the_task() {
2646 let mut r = TaskRegistry::new(8, TaskAcceptance::Queue);
2647 r.enqueue("tsk_a", "ctx_001");
2648 let timestamp = timestamp_of(&r, "tsk_a");
2649 assert!(timestamp.ends_with('Z'), "{timestamp}");
2650 assert_eq!(
2651 timestamp.len(),
2652 "2026-10-13T09:00:00.123Z".len(),
2653 "{timestamp}"
2654 );
2655 assert_eq!(instant(×tamp), r.status_times["tsk_a"]);
2656 let at = TaskFilter {
2657 status_after: Some(instant(×tamp)),
2658 ..every_task()
2659 };
2660 assert_eq!(listed(&r.list_tasks(&at, None, 10, |_| true)), ["tsk_a"]);
2661 let after = TaskFilter {
2662 status_after: Some(instant(×tamp) + chrono::Duration::milliseconds(1)),
2663 ..every_task()
2664 };
2665 assert!(r.list_tasks(&after, None, 10, |_| true).tasks.is_empty());
2666 }
2667
2668 #[test]
2669 fn withdraw_drops_the_status_time() {
2670 let mut r = TaskRegistry::new(8, TaskAcceptance::Queue);
2671 r.enqueue("tsk_a", "ctx_001");
2672 r.withdraw("tsk_a");
2673 assert!(!r.status_times.contains_key("tsk_a"));
2674 assert!(r.get_task("tsk_a").is_none());
2675 }
2676
2677 #[test]
2680 fn list_tasks_filters_by_status_time_before_paging() {
2681 let mut r = TaskRegistry::new(8, TaskAcceptance::Queue);
2682 for id in ["tsk_1", "tsk_2", "tsk_3", "tsk_4"] {
2683 r.enqueue(id, "ctx_001");
2684 }
2685 r.request_cancel("tsk_3");
2686 let base = instant("2026-10-13T09:00:00Z");
2687 for (offset, id) in ["tsk_1", "tsk_2", "tsk_3", "tsk_4"].iter().enumerate() {
2688 r.status_times.insert(
2689 id.to_string(),
2690 base + chrono::Duration::seconds(offset as i64),
2691 );
2692 }
2693 let since = |seconds: i64| TaskFilter {
2694 status_after: Some(base + chrono::Duration::seconds(seconds)),
2695 ..every_task()
2696 };
2697 let first = r.list_tasks(&since(1), None, 2, |_| true);
2698 assert_eq!(listed(&first), ["tsk_3", "tsk_4"]);
2699 assert_eq!(first.total, 3);
2700 let second = r.list_tasks(&since(1), first.next.as_ref(), 2, |_| true);
2701 assert_eq!(listed(&second), ["tsk_2"]);
2702 assert_eq!((second.total, second.next), (3, None));
2703
2704 let canceled_since = TaskFilter {
2705 state: StateFilter::Only(TaskState::Canceled),
2706 ..since(1)
2707 };
2708 let page = r.list_tasks(&canceled_since, None, 10, |_| true);
2709 assert_eq!((listed(&page), page.total), (vec!["tsk_3"], 1));
2710 assert_eq!(r.list_tasks(&since(4), None, 10, |_| true).total, 0);
2711 assert_eq!(r.list_tasks(&every_task(), None, 10, |_| true).total, 4);
2712 }
2713
2714 #[test]
2716 fn list_tasks_filters_by_context_state_and_visibility() {
2717 let mut r = TaskRegistry::new(8, TaskAcceptance::Queue);
2718 r.enqueue("tsk_a", "ctx_one");
2719 r.record_owner("tsk_a", "alice");
2720 r.enqueue("tsk_b", "ctx_two");
2721 r.record_owner("tsk_b", "alice");
2722 r.enqueue("tsk_c", "ctx_one");
2723 r.record_owner("tsk_c", "bob");
2724 r.request_cancel("tsk_c");
2725 let alice = |task_id: &str| r.owner(task_id) == Some("alice");
2726 let page = r.list_tasks(&every_task(), None, 10, alice);
2727 assert_eq!((listed(&page), page.total), (vec!["tsk_b", "tsk_a"], 2));
2728 let in_one = TaskFilter {
2729 context_id: Some("ctx_one".to_string()),
2730 state: StateFilter::Any,
2731 status_after: None,
2732 };
2733 let page = r.list_tasks(&in_one, None, 10, |_| true);
2734 assert_eq!((listed(&page), page.total), (vec!["tsk_c", "tsk_a"], 2));
2735 let canceled = TaskFilter {
2736 context_id: None,
2737 state: StateFilter::Only(TaskState::Canceled),
2738 status_after: None,
2739 };
2740 let page = r.list_tasks(&canceled, None, 10, |_| true);
2741 assert_eq!(listed(&page), ["tsk_c"]);
2742 let page = r.list_tasks(&canceled, None, 10, alice);
2743 assert_eq!((page.tasks.len(), page.total, page.next), (0, 0, None));
2744 let nothing = TaskFilter {
2745 context_id: None,
2746 state: StateFilter::Nothing,
2747 status_after: None,
2748 };
2749 assert_eq!(r.list_tasks(¬hing, None, 10, |_| true).total, 0);
2750 }
2751
2752 #[test]
2753 fn list_cursor_reads_only_what_it_wrote() {
2754 let cursor = ListCursor {
2755 revision: 42,
2756 task_id: "tsk_01a1".to_string(),
2757 };
2758 let token = cursor.encode();
2759 assert!(!token.contains('='), "{token}");
2760 assert_eq!(ListCursor::decode(&token), Some(cursor));
2761 use base64::Engine as _;
2762 let encode = |text: &str| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(text);
2763 for garbage in [
2764 "not-a-token".to_string(),
2765 "".to_string(),
2766 "!!".to_string(),
2767 encode("42"),
2768 encode("42:"),
2769 encode("x:tsk_1"),
2770 encode("-1:tsk_1"),
2771 encode("+1:tsk_1"),
2772 ] {
2773 assert_eq!(ListCursor::decode(&garbage), None, "{garbage}");
2774 }
2775 }
2776
2777 #[test]
2778 fn list_state_filter_reads_every_v1_state_name() {
2779 for state in TaskState::ALL {
2780 assert_eq!(
2781 TaskState::from_wire_name(state.wire_name()),
2782 Some(state.clone())
2783 );
2784 assert_eq!(
2785 StateFilter::from_wire_name(state.wire_name()),
2786 Some(StateFilter::Only(state))
2787 );
2788 }
2789 assert_eq!(
2790 StateFilter::from_wire_name("TASK_STATE_UNSPECIFIED"),
2791 Some(StateFilter::Any)
2792 );
2793 assert_eq!(
2794 StateFilter::from_wire_name("TASK_STATE_AUTH_REQUIRED"),
2795 Some(StateFilter::Nothing)
2796 );
2797 for other in ["done", "completed", "task_state_completed", ""] {
2798 assert_eq!(StateFilter::from_wire_name(other), None, "{other}");
2799 assert_eq!(TaskState::from_wire_name(other), None, "{other}");
2800 }
2801 }
2802
2803 #[test]
2804 fn request_cancel_on_a_running_task_records_canceled() {
2805 let mut r = running_registry("tsk_001");
2806 let signal = r.cancel_watch("tsk_001");
2807 assert_eq!(r.request_cancel("tsk_001"), CancelOutcome::Accepted);
2808 assert!(signal.is_canceled());
2809 assert!(r.is_canceled("tsk_001"));
2810 let task = r.get_task("tsk_001").unwrap();
2811 assert_eq!(task.status.state, TaskState::Canceled);
2812 }
2813
2814 #[test]
2815 fn request_cancel_on_a_completed_task_leaves_it_alone() {
2816 let mut r = running_registry("tsk_001");
2817 r.finish_task(TaskState::Completed);
2818 assert_eq!(r.request_cancel("tsk_001"), CancelOutcome::AlreadyTerminal);
2819 assert_eq!(
2820 r.get_task("tsk_001").unwrap().status.state,
2821 TaskState::Completed
2822 );
2823 let mut r = running_registry("tsk_002");
2825 assert_eq!(r.request_cancel("tsk_002"), CancelOutcome::Accepted);
2826 assert_eq!(r.request_cancel("tsk_002"), CancelOutcome::AlreadyTerminal);
2827 }
2828
2829 #[test]
2830 fn cancel_every_live_cancels_live_tasks_in_order_and_leaves_terminal_ones() {
2831 let mut r = TaskRegistry::new(8, TaskAcceptance::Queue);
2832 r.enqueue("tsk_c", "ctx_001");
2833 r.enqueue("tsk_a", "ctx_001");
2834 r.enqueue("tsk_done", "ctx_001");
2835 r.start_task("tsk_done".to_string(), "ctx_001".to_string(), TaskLane::Bg);
2836 r.finish_task(TaskState::Completed);
2837 r.start_task("tsk_a".to_string(), "ctx_001".to_string(), TaskLane::Bg);
2838 let running = r.cancel_watch("tsk_a");
2839
2840 assert_eq!(r.cancel_every_live(false), vec!["tsk_a", "tsk_c"]);
2841 assert!(running.is_canceled());
2842 assert!(r.is_canceled("tsk_c"));
2843 assert_eq!(
2844 r.get_task("tsk_done").unwrap().status.state,
2845 TaskState::Completed
2846 );
2847 assert!(r.cancel_every_live(false).is_empty());
2848 }
2849
2850 #[test]
2853 fn cancel_every_live_at_the_deadline_spares_a_task_holding_a_standing_answer() {
2854 let mut r = TaskRegistry::new(8, TaskAcceptance::Queue);
2855 r.enqueue("tsk_a", "ctx_001");
2856 r.enqueue("tsk_b", "ctx_001");
2857 r.start_task("tsk_a".to_string(), "ctx_001".to_string(), TaskLane::Bg);
2858 let running = r.cancel_watch("tsk_a");
2859 r.mark_standing_answer("tsk_a");
2860
2861 assert_eq!(r.cancel_every_live(true), vec!["tsk_b"]);
2862 assert!(running.is_canceled());
2863 assert!(!r.is_canceled("tsk_a"));
2864 assert!(!r.get_task("tsk_a").unwrap().status.state.is_terminal());
2865 assert!(r.stopped_at_deadline("tsk_a"));
2866 assert!(
2867 r.is_canceled("tsk_b"),
2868 "an unmarked task is cancelled at once"
2869 );
2870
2871 assert_eq!(
2872 r.finish_task(TaskState::Completed),
2873 Some(TaskState::Completed)
2874 );
2875 assert_eq!(
2876 r.get_task("tsk_a").unwrap().status.state,
2877 TaskState::Completed
2878 );
2879 assert!(
2880 !r.stopped_at_deadline("tsk_a"),
2881 "finish_task clears the mark"
2882 );
2883 }
2884
2885 #[test]
2888 fn cancel_every_live_at_the_deadline_marks_the_tasks_it_cancels() {
2889 let mut r = TaskRegistry::new(8, TaskAcceptance::Queue);
2890 r.enqueue("tsk_a", "ctx_001");
2891 r.enqueue("tsk_b", "ctx_001");
2892 r.start_task("tsk_a".to_string(), "ctx_001".to_string(), TaskLane::Bg);
2893 assert_eq!(r.request_cancel("tsk_b"), CancelOutcome::Accepted);
2894
2895 assert_eq!(r.cancel_every_live(true), vec!["tsk_a"]);
2896 assert!(r.canceled_at_deadline("tsk_a"));
2897 assert!(
2898 !r.canceled_at_deadline("tsk_b"),
2899 "a person's cancel came first"
2900 );
2901 assert_eq!(
2902 r.finish_task(TaskState::Completed),
2903 Some(TaskState::Canceled)
2904 );
2905 assert!(
2906 !r.canceled_at_deadline("tsk_a"),
2907 "finish_task clears the mark"
2908 );
2909
2910 let mut r = running_registry("tsk_001");
2911 assert_eq!(r.cancel_every_live(false), vec!["tsk_001"]);
2912 assert!(!r.canceled_at_deadline("tsk_001"));
2913 }
2914
2915 #[test]
2917 fn cancel_every_live_without_sparing_cancels_a_task_holding_a_standing_answer() {
2918 let mut r = running_registry("tsk_001");
2919 r.mark_standing_answer("tsk_001");
2920 assert_eq!(r.cancel_every_live(false), vec!["tsk_001"]);
2921 assert!(r.is_canceled("tsk_001"));
2922 assert!(!r.stopped_at_deadline("tsk_001"));
2923 assert_eq!(
2924 r.finish_task(TaskState::Completed),
2925 Some(TaskState::Canceled)
2926 );
2927 }
2928
2929 #[test]
2931 fn cancel_every_live_at_the_deadline_cancels_a_task_whose_mark_was_cleared() {
2932 let mut r = running_registry("tsk_001");
2933 r.mark_standing_answer("tsk_001");
2934 r.clear_standing_answer("tsk_001");
2935 assert_eq!(r.cancel_every_live(true), vec!["tsk_001"]);
2936 assert!(r.is_canceled("tsk_001"));
2937 assert!(!r.stopped_at_deadline("tsk_001"));
2938 }
2939
2940 #[test]
2941 fn request_cancel_on_an_unknown_id_is_unknown() {
2942 let mut r = make_registry();
2943 assert_eq!(r.request_cancel("tsk_doesnotexist"), CancelOutcome::Unknown);
2944 assert!(!r.is_canceled("tsk_doesnotexist"));
2945 }
2946
2947 #[test]
2948 fn finish_task_does_not_overwrite_an_accepted_cancel() {
2949 let mut r = running_registry("tsk_001");
2950 assert_eq!(r.request_cancel("tsk_001"), CancelOutcome::Accepted);
2951 assert_eq!(
2952 r.finish_task(TaskState::Completed),
2953 Some(TaskState::Canceled),
2954 "the state recorded is the accepted cancel, and the caller is told so"
2955 );
2956 assert_eq!(
2957 r.get_task("tsk_001").unwrap().status.state,
2958 TaskState::Canceled
2959 );
2960 assert_eq!(
2961 r.finish_task(TaskState::Failed),
2962 None,
2963 "a slot that is no longer running records nothing"
2964 );
2965 assert_eq!(
2966 r.get_task("tsk_001").unwrap().status.state,
2967 TaskState::Canceled
2968 );
2969 }
2970
2971 #[test]
2972 fn finish_task_returns_the_state_it_recorded() {
2973 let mut r = running_registry("tsk_001");
2974 assert_eq!(
2975 r.finish_task(TaskState::Completed),
2976 Some(TaskState::Completed)
2977 );
2978 assert_eq!(make_registry().finish_task(TaskState::Failed), None);
2979 }
2980
2981 #[test]
2982 fn an_input_timeout_leaves_the_task_working_and_is_read_once() {
2983 let mut r = running_registry("tsk_001");
2984 let (tx, _rx) = oneshot::channel();
2985 r.set_input_required("tsk_001", "prompt".into(), tx)
2986 .unwrap();
2987 r.record_input_timeout("tsk_001");
2988 assert_eq!(
2989 r.get_task("tsk_001").unwrap().status.state,
2990 TaskState::Working,
2991 "the task's terminal state is not the wait's to record"
2992 );
2993 assert!(
2994 r.get_input_prompt("tsk_001").is_none(),
2995 "the waiter is gone"
2996 );
2997 assert!(r.take_input_timeout("tsk_001"));
2998 assert!(
2999 !r.take_input_timeout("tsk_001"),
3000 "a reopened attempt does not end on the same timeout"
3001 );
3002 assert!(!r.take_input_timeout("tsk_other"));
3003 }
3004
3005 #[test]
3006 fn cancelling_a_queued_task_frees_its_queue_slot() {
3007 let mut r = TaskRegistry::new(2, TaskAcceptance::Queue);
3008 r.enqueue("tsk_a", "ctx_001");
3009 r.enqueue("tsk_b", "ctx_001");
3010 assert!(!r.can_accept(), "the queue is full");
3011 assert_eq!(r.request_cancel("tsk_b"), CancelOutcome::Accepted);
3012 assert!(r.can_accept(), "a cancelled task holds no slot");
3013 }
3014
3015 #[test]
3016 fn close_to_new_work_rejects_every_submitted_task_and_nothing_else() {
3017 let mut r = TaskRegistry::new(4, TaskAcceptance::Queue);
3018 for id in ["tsk_a", "tsk_b", "tsk_c", "tsk_d"] {
3019 r.enqueue(id, &format!("ctx_{id}"));
3020 }
3021 r.start_task("tsk_a".to_string(), "ctx_tsk_a".to_string(), TaskLane::Bg);
3022 let (tx, _rx) = oneshot::channel();
3023 r.set_input_required("tsk_a", "prompt".into(), tx).unwrap();
3024 assert_eq!(r.request_cancel("tsk_c"), CancelOutcome::Accepted);
3025 let pending_before = r.pending_count;
3026
3027 let refused = r.close_to_new_work();
3028
3029 assert_eq!(
3030 refused,
3031 vec![
3032 ("tsk_b".to_string(), "ctx_tsk_b".to_string()),
3033 ("tsk_d".to_string(), "ctx_tsk_d".to_string()),
3034 ]
3035 );
3036 for id in ["tsk_b", "tsk_d"] {
3037 assert_eq!(r.get_task(id).unwrap().status.state, TaskState::Rejected);
3038 }
3039 assert_eq!(r.pending_count, pending_before - 2);
3040 assert_eq!(r.pending_count, 0);
3041 assert_eq!(
3042 r.get_task("tsk_a").unwrap().status.state,
3043 TaskState::InputRequired,
3044 "a live task that is not submitted is left alone"
3045 );
3046 assert_eq!(
3047 r.get_task("tsk_c").unwrap().status.state,
3048 TaskState::Canceled
3049 );
3050 assert_eq!(r.request_cancel("tsk_b"), CancelOutcome::AlreadyTerminal);
3051 assert!(!r.is_canceled("tsk_b"));
3052 }
3053
3054 #[test]
3055 fn a_closed_registry_accepts_nothing_and_closes_once() {
3056 for (depth, mode) in [(1, TaskAcceptance::Single), (4, TaskAcceptance::Queue)] {
3057 let mut r = TaskRegistry::new(depth, mode.clone());
3058 assert!(r.can_accept());
3059 assert!(!r.is_closed());
3060 r.enqueue("tsk_a", "ctx_001");
3061 assert_eq!(r.close_to_new_work().len(), 1);
3062 assert!(r.is_closed());
3063 assert!(!r.can_accept(), "{mode:?}: a closed registry refuses work");
3064 assert!(r.close_to_new_work().is_empty(), "{mode:?}: closes once");
3065 assert!(!r.can_accept());
3066 }
3067 }
3068
3069 #[test]
3074 fn tasks_held_at_once_matches_what_the_registry_accepts() {
3075 const SAFETY_CAP: usize = 64;
3076 for (mode, depth, expected) in [
3077 (TaskAcceptance::None, 1, 0),
3078 (TaskAcceptance::Single, 1, 1),
3079 (TaskAcceptance::Queue, 1, 2),
3080 (TaskAcceptance::Queue, 3, 4),
3081 ] {
3082 let mut r = TaskRegistry::new(depth, mode.clone());
3083 let mut accepted = 0;
3084 while r.can_accept() && accepted < SAFETY_CAP {
3085 let task_id = format!("tsk_{accepted}");
3086 r.enqueue(&task_id, "ctx_001");
3087 if accepted == 0 {
3088 r.start_task(task_id, "ctx_001".to_string(), TaskLane::Peer);
3089 }
3090 accepted += 1;
3091 }
3092 let lifecycle = murmur_artifact::LifecycleConfig {
3093 task_acceptance: mode.clone(),
3094 queue_depth: depth,
3095 ..Default::default()
3096 };
3097 assert_eq!(accepted, expected, "{mode:?} at depth {depth}");
3098 assert_eq!(
3099 accepted,
3100 lifecycle.tasks_held_at_once(),
3101 "{mode:?} at depth {depth}: tasks_held_at_once drifted from the registry"
3102 );
3103 }
3104 }
3105
3106 #[test]
3107 fn task_state_serializes_as_its_wire_name_and_reads_only_that() {
3108 for state in TaskState::ALL {
3109 let wire = serde_json::to_value(&state).unwrap();
3110 assert_eq!(wire, serde_json::json!(state.wire_name()));
3111 assert_eq!(
3112 serde_json::from_value::<TaskState>(wire).unwrap(),
3113 state,
3114 "{state:?}"
3115 );
3116 assert!(
3117 serde_json::from_value::<TaskState>(serde_json::json!(state.as_str())).is_err(),
3118 "{state:?}: murmur's word is not a wire state"
3119 );
3120 }
3121 for refused in [
3122 serde_json::json!("TASK_STATE_UNSPECIFIED"),
3123 serde_json::json!("TASK_STATE_AUTH_REQUIRED"),
3124 serde_json::json!("task_state_working"),
3125 serde_json::json!(1),
3126 serde_json::json!(null),
3127 ] {
3128 assert!(
3129 serde_json::from_value::<TaskState>(refused.clone()).is_err(),
3130 "{refused}"
3131 );
3132 }
3133 }
3134
3135 #[test]
3136 fn task_state_as_str_is_murmurs_word() {
3137 let words: Vec<&str> = TaskState::ALL.iter().map(TaskState::as_str).collect();
3138 assert_eq!(
3139 words,
3140 [
3141 "submitted",
3142 "working",
3143 "completed",
3144 "failed",
3145 "canceled",
3146 "input-required",
3147 "rejected"
3148 ]
3149 );
3150 }
3151
3152 #[test]
3153 fn murmur_state_name_maps_the_eight_v1_states_and_nothing_else() {
3154 for state in TaskState::ALL {
3155 assert_eq!(murmur_state_name(state.wire_name()), Some(state.as_str()));
3156 }
3157 assert_eq!(
3158 murmur_state_name("TASK_STATE_AUTH_REQUIRED"),
3159 Some("auth-required")
3160 );
3161 for other in [
3162 "TASK_STATE_UNSPECIFIED",
3163 "working",
3164 "",
3165 "TASK_STATE_working",
3166 ] {
3167 assert_eq!(murmur_state_name(other), None, "{other:?}");
3168 }
3169 }
3170
3171 #[test]
3172 fn roles_serialize_as_their_proto_names() {
3173 assert_eq!(
3174 serde_json::to_value(Role::User).unwrap(),
3175 serde_json::json!("ROLE_USER")
3176 );
3177 assert_eq!(
3178 serde_json::to_value(Role::Agent).unwrap(),
3179 serde_json::json!("ROLE_AGENT")
3180 );
3181 assert!(serde_json::from_value::<Role>(serde_json::json!("agent")).is_err());
3182 }
3183
3184 #[test]
3185 fn finish_task_cleans_up_input_waiters() {
3186 let mut r = running_registry("tsk_001");
3187 let (tx, _rx) = oneshot::channel();
3188 r.set_input_required("tsk_001", "prompt".into(), tx)
3189 .unwrap();
3190 r.finish_task(TaskState::Failed);
3191 assert!(r.get_input_prompt("tsk_001").is_none());
3193 }
3194
3195 #[test]
3196 fn a2a_error_table_is_the_spec_table_in_code_order() {
3197 let table: Vec<(i32, &str)> = A2aError::ALL
3198 .into_iter()
3199 .map(|error| (error.code(), error.reason()))
3200 .collect();
3201 assert_eq!(
3202 table,
3203 [
3204 (-32001, "TASK_NOT_FOUND"),
3205 (-32002, "TASK_NOT_CANCELABLE"),
3206 (-32003, "PUSH_NOTIFICATION_NOT_SUPPORTED"),
3207 (-32004, "UNSUPPORTED_OPERATION"),
3208 (-32005, "CONTENT_TYPE_NOT_SUPPORTED"),
3209 (-32006, "INVALID_AGENT_RESPONSE"),
3210 (-32007, "EXTENDED_AGENT_CARD_NOT_CONFIGURED"),
3211 (-32008, "EXTENSION_SUPPORT_REQUIRED"),
3212 (-32009, "VERSION_NOT_SUPPORTED"),
3213 ]
3214 );
3215 }
3216
3217 #[test]
3218 fn a2a_error_from_code_round_trips_and_knows_no_other_code() {
3219 for error in A2aError::ALL {
3220 assert_eq!(A2aError::from_code(error.code()), Some(error));
3221 }
3222 for code in [-32000, -32010, -32099, -32600, -31001, 0] {
3223 assert_eq!(A2aError::from_code(code), None, "{code}");
3224 }
3225 }
3226
3227 #[test]
3228 fn a2a_murmur_errors_lie_outside_the_reserved_range() {
3229 let table: Vec<(i32, &str)> = MurmurError::ALL
3230 .into_iter()
3231 .map(|error| (error.code(), error.reason()))
3232 .collect();
3233 assert_eq!(
3234 table,
3235 [
3236 (-31001, "COMPLETION_MISADDRESSED"),
3237 (-31002, "COMPLETION_NOT_AWAITED")
3238 ]
3239 );
3240 for error in MurmurError::ALL {
3241 assert!(!(-32768..=-32000).contains(&error.code()), "{error:?}");
3242 }
3243 }
3244
3245 fn wire(response: &JsonRpcResponse) -> Value {
3246 serde_json::to_value(response).unwrap()
3247 }
3248
3249 #[test]
3250 fn a2a_error_carries_one_error_info_with_string_metadata() {
3251 let response = JsonRpcResponse::a2a_error(
3252 Value::from(7),
3253 A2aError::TaskNotCancelable,
3254 "Task cannot be canceled",
3255 &[("taskId", "tsk_1".into()), ("state", "completed".into())],
3256 );
3257 assert_eq!(
3258 wire(&response),
3259 serde_json::json!({
3260 "jsonrpc": "2.0",
3261 "id": 7,
3262 "error": {
3263 "code": -32002,
3264 "message": "Task cannot be canceled",
3265 "data": [{
3266 "@type": "type.googleapis.com/google.rpc.ErrorInfo",
3267 "reason": "TASK_NOT_CANCELABLE",
3268 "domain": "a2a-protocol.org",
3269 "metadata": {"taskId": "tsk_1", "state": "completed"},
3270 }],
3271 },
3272 })
3273 );
3274 }
3275
3276 #[test]
3277 fn a2a_murmur_error_is_in_the_murmur_domain() {
3278 let response = JsonRpcResponse::murmur_error(
3279 Value::from("a"),
3280 MurmurError::CompletionNotAwaited,
3281 "nobody waits",
3282 &[],
3283 );
3284 let error = &wire(&response)["error"];
3285 assert_eq!(error["code"], -31002);
3286 assert_eq!(error["data"][0]["reason"], "COMPLETION_NOT_AWAITED");
3287 assert_eq!(error["data"][0]["domain"], "murmur.nexus");
3288 assert_eq!(error["data"][0]["metadata"], serde_json::json!({}));
3289 }
3290
3291 #[test]
3292 fn a2a_standard_errors_carry_no_data() {
3293 let error = &wire(&JsonRpcResponse::err(
3294 Value::Null,
3295 -32601,
3296 "Method not found",
3297 ))["error"];
3298 assert!(error.get("data").is_none(), "{error}");
3299 }
3300
3301 fn refusal(body: &str) -> Value {
3302 wire(&JsonRpcRequest::from_body(body).expect_err(body))
3303 }
3304
3305 #[test]
3306 fn a2a_from_body_reads_a_request_and_defaults_params() {
3307 let req = JsonRpcRequest::from_body(
3308 r#"{"jsonrpc":"2.0","id":"x","method":"GetExtendedAgentCard","extra":1}"#,
3309 )
3310 .unwrap();
3311 assert_eq!(req.id, Value::from("x"));
3312 assert_eq!(req.method, "GetExtendedAgentCard");
3313 assert_eq!(req.params, serde_json::json!({}));
3314
3315 let req = JsonRpcRequest::from_body(
3316 r#"{"jsonrpc":"2.0","id":3,"method":"GetTask","params":[1]}"#,
3317 )
3318 .unwrap();
3319 assert_eq!(req.id, Value::from(3));
3320 assert_eq!(req.params, serde_json::json!([1]), "a method judges params");
3321 }
3322
3323 #[test]
3324 fn a2a_from_body_refuses_what_is_not_json() {
3325 let answer = refusal("{not json");
3326 assert_eq!(answer["id"], Value::Null);
3327 assert_eq!(answer["error"]["code"], -32700);
3328 assert_eq!(answer["error"]["message"], "Invalid JSON payload");
3329 }
3330
3331 #[test]
3332 fn a2a_from_body_refuses_what_is_not_a_json_rpc_request() {
3333 for (body, id) in [
3334 (
3335 r#"{"jsonrpc":"1.0","id":1,"method":"GetTask"}"#,
3336 Value::from(1),
3337 ),
3338 (r#"{"id":1,"method":"GetTask"}"#, Value::from(1)),
3339 (
3340 r#"{"jsonrpc":2.0,"id":1,"method":"GetTask"}"#,
3341 Value::from(1),
3342 ),
3343 (
3344 r#"[{"jsonrpc":"2.0","id":1,"method":"GetTask"}]"#,
3345 Value::Null,
3346 ),
3347 (r#""GetTask""#, Value::Null),
3348 (r#"{"jsonrpc":"2.0","id":"a","method":7}"#, Value::from("a")),
3349 (r#"{"jsonrpc":"2.0","id":"a"}"#, Value::from("a")),
3350 (
3351 r#"{"jsonrpc":"2.0","id":{"a":1},"method":"GetTask"}"#,
3352 Value::Null,
3353 ),
3354 (
3355 r#"{"jsonrpc":"2.0","id":null,"method":"GetTask"}"#,
3356 Value::Null,
3357 ),
3358 (
3359 r#"{"jsonrpc":"2.0","id":1.5,"method":"GetTask"}"#,
3360 Value::Null,
3361 ),
3362 (r#"{"jsonrpc":"2.0","method":"GetTask"}"#, Value::Null),
3363 ] {
3364 let answer = refusal(body);
3365 assert_eq!(answer["error"]["code"], -32600, "{body}");
3366 assert_eq!(
3367 answer["error"]["message"], "Request payload validation error",
3368 "{body}"
3369 );
3370 assert_eq!(answer["id"], id, "{body}");
3371 }
3372 }
3373
3374 fn versions(values: &[&str]) -> Vec<String> {
3375 values.iter().map(|value| value.to_string()).collect()
3376 }
3377
3378 #[test]
3379 fn a2a_version_accepts_1_0_with_or_without_a_patch() {
3380 for value in ["1.0", " 1.0 ", "1.0.0", "1.0.1", "1.0.17"] {
3381 assert!(accepts_a2a_version(&versions(&[value])), "{value:?}");
3382 }
3383 }
3384
3385 #[test]
3386 fn a2a_version_refuses_every_other_value_and_more_than_one_line() {
3387 for values in [
3388 &[][..],
3389 &[""],
3390 &["0.3"],
3391 &["0.3.0"],
3392 &["2.0"],
3393 &["1"],
3394 &["1.1"],
3395 &["1.0x"],
3396 &["1.0."],
3397 &["1.0.a"],
3398 &["1.00"],
3399 &["v1.0"],
3400 &["1.0", "1.0"],
3401 ] {
3402 assert!(!accepts_a2a_version(&versions(values)), "{values:?}");
3403 }
3404 }
3405
3406 #[test]
3407 fn an_extension_is_activated_by_an_exact_entry_on_any_line() {
3408 const URI: &str =
3409 "https://docs.murmur.nexus/reference/streaming-protocol/#murmur-stream-v1";
3410 let other = "https://example.com/x/v1";
3411 for values in [
3412 vec![URI.to_string()],
3413 vec![format!(" {URI} ")],
3414 vec![format!("{other}, {URI}")],
3415 vec![format!("{other},,{URI},")],
3416 vec![other.to_string(), URI.to_string()],
3417 ] {
3418 assert!(activates_extension(&values, URI), "{values:?}");
3419 }
3420 for values in [
3421 vec![],
3422 vec![String::new()],
3423 vec![other.to_string()],
3424 vec![format!("{URI}/")],
3425 vec![URI.to_uppercase()],
3426 vec![format!("{URI} {other}")],
3427 ] {
3428 assert!(!activates_extension(&values, URI), "{values:?}");
3429 }
3430 }
3431
3432 #[test]
3433 fn a2a_version_refusal_names_what_was_sent() {
3434 let answer = wire(&version_not_supported(Value::from(4), &versions(&[])));
3435 assert_eq!(answer["id"], 4);
3436 assert_eq!(answer["error"]["code"], -32009);
3437 let info = &answer["error"]["data"][0];
3438 assert_eq!(info["reason"], "VERSION_NOT_SUPPORTED");
3439 assert_eq!(info["domain"], "a2a-protocol.org");
3440 assert_eq!(
3441 info["metadata"],
3442 serde_json::json!({"requestedVersion": "", "supportedVersions": "1.0"})
3443 );
3444
3445 let answer = wire(&version_not_supported(Value::Null, &versions(&[" 0.3 "])));
3446 assert_eq!(
3447 answer["error"]["data"][0]["metadata"]["requestedVersion"],
3448 "0.3"
3449 );
3450 let answer = wire(&version_not_supported(
3451 Value::Null,
3452 &versions(&["1.0", "1.0"]),
3453 ));
3454 assert_eq!(
3455 answer["error"]["data"][0]["metadata"]["requestedVersion"],
3456 "1.0, 1.0"
3457 );
3458 }
3459
3460 #[test]
3463 fn a_part_serializes_its_one_content_field_and_reads_back() {
3464 for (part, wire) in [
3465 (
3466 Part::text("hi"),
3467 serde_json::json!({"text": "hi", "mediaType": "text/plain"}),
3468 ),
3469 (
3470 Part::data(serde_json::json!({"a": [1]})),
3471 serde_json::json!({"data": {"a": [1]}, "mediaType": "application/json"}),
3472 ),
3473 (
3474 Part::data(Value::Null),
3475 serde_json::json!({"data": null, "mediaType": "application/json"}),
3476 ),
3477 ] {
3478 assert_eq!(serde_json::to_value(&part).unwrap(), wire);
3479 assert_eq!(serde_json::from_value::<Part>(wire).unwrap(), part);
3480 }
3481 for refused in [
3482 serde_json::json!({}),
3483 serde_json::json!({"text": "a", "data": 1}),
3484 serde_json::json!({"kind": "file", "file": {"uri": "x"}}),
3485 serde_json::json!({"text": 7}),
3486 serde_json::json!("text"),
3487 ] {
3488 assert!(
3489 serde_json::from_value::<Part>(refused.clone()).is_err(),
3490 "{refused}"
3491 );
3492 }
3493 }
3494
3495 fn params(message: Value) -> Value {
3496 serde_json::json!({ "message": message })
3497 }
3498
3499 fn user(parts: Value) -> Value {
3500 serde_json::json!({"messageId": "msg_1", "role": "ROLE_USER", "parts": parts})
3501 }
3502
3503 fn invalid_params(params: &Value) -> String {
3504 match read_send_params(params) {
3505 Err(MessageRefusal::InvalidParams(message)) => message,
3506 other => panic!("expected -32602 for {params}, got {other:?}"),
3507 }
3508 }
3509
3510 #[test]
3511 fn read_send_params_reads_a_v1_message_in_full() {
3512 let message = read_send_params(¶ms(serde_json::json!({
3513 "messageId": "msg_1",
3514 "contextId": "ctx_1",
3515 "taskId": "tsk_1",
3516 "role": "ROLE_USER",
3517 "referenceTaskIds": ["tsk_a", "tsk_b", "tsk_a"],
3518 "parts": [{"text": "a"}, {"data": null, "filename": "f.json"}],
3519 "metadata": {"ignored": true},
3520 "extensions": ["https://example.com/x"],
3521 "unknown": 1,
3522 })))
3523 .unwrap();
3524 assert_eq!(message.message_id, "msg_1");
3525 assert_eq!(message.context_id.as_deref(), Some("ctx_1"));
3526 assert_eq!(message.task_id.as_deref(), Some("tsk_1"));
3527 assert_eq!(message.reference_task_ids, ["tsk_a", "tsk_b"]);
3528 assert_eq!(message.part_kinds(), ["text", "data"]);
3529 assert_eq!(message.parts[1].filename.as_deref(), Some("f.json"));
3530
3531 let message = read_send_params(¶ms(serde_json::json!({
3532 "messageId": "msg_1", "role": "ROLE_USER", "taskId": "", "parts": [{"text": "a"}],
3533 })))
3534 .unwrap();
3535 assert_eq!(message.task_id, None, "an empty taskId names no task");
3536 }
3537
3538 #[test]
3541 fn read_send_params_refuses_structure_in_order() {
3542 let text = serde_json::json!([{"text": "a"}]);
3543 for (params, expected) in [
3544 (serde_json::json!({}), "params.message"),
3545 (user(text.clone()), "params.message"),
3546 (serde_json::json!({"message": "hi"}), "params.message"),
3547 (
3548 params(serde_json::json!({"role": "ROLE_USER", "parts": text})),
3549 "messageId",
3550 ),
3551 (
3552 params(serde_json::json!({"messageId": "", "role": "ROLE_USER", "parts": text})),
3553 "messageId",
3554 ),
3555 (
3556 params(serde_json::json!({"messageId": "m", "role": "user", "parts": []})),
3557 "ROLE_USER",
3558 ),
3559 (
3560 params(serde_json::json!({"messageId": "m", "role": "ROLE_AGENT", "parts": text})),
3561 "ROLE_USER",
3562 ),
3563 (
3564 params(serde_json::json!({"messageId": "m", "role": 1, "parts": text})),
3565 "ROLE_USER",
3566 ),
3567 (
3568 params(serde_json::json!({"messageId": "m", "parts": text})),
3569 "ROLE_USER",
3570 ),
3571 (params(user(serde_json::json!([]))), "non-empty array"),
3572 (
3573 params(user(serde_json::json!({"text": "a"}))),
3574 "non-empty array",
3575 ),
3576 (
3577 params(user(serde_json::json!([{"text": "a"}, {}]))),
3578 "message.parts[1] sets none",
3579 ),
3580 (
3581 params(user(serde_json::json!([{"text": "a", "data": 1}]))),
3582 "message.parts[0] sets text and data",
3583 ),
3584 (
3585 params(user(
3586 serde_json::json!([{"kind": "file", "file": {"uri": "x"}}]),
3587 )),
3588 "message.parts[0] sets none",
3589 ),
3590 (
3591 params(user(serde_json::json!([{"url": 7}, {}]))),
3592 "message.parts[1] sets none",
3593 ),
3594 (
3595 params(user(serde_json::json!([{"text": 7}]))),
3596 "message.parts[0].text is not a string",
3597 ),
3598 (
3599 params(user(serde_json::json!([{"data": 1, "mediaType": 2}]))),
3600 "message.parts[0].mediaType",
3601 ),
3602 (
3603 params(serde_json::json!({"messageId": "m", "role": "ROLE_USER",
3604 "parts": [{"url": "u"}], "taskId": 7})),
3605 "message.taskId",
3606 ),
3607 (
3608 params(serde_json::json!({"messageId": "m", "role": "ROLE_USER",
3609 "parts": text, "contextId": ["c"]})),
3610 "message.contextId",
3611 ),
3612 (
3613 params(serde_json::json!({"messageId": "m", "role": "ROLE_USER",
3614 "parts": [{"raw": "aGk="}], "referenceTaskIds": [""]})),
3615 "referenceTaskIds",
3616 ),
3617 (
3618 params(serde_json::json!({"messageId": "m", "role": "ROLE_USER",
3619 "parts": text, "referenceTaskIds": [7]})),
3620 "referenceTaskIds",
3621 ),
3622 (
3623 params(serde_json::json!({"messageId": "m", "role": "ROLE_USER",
3624 "parts": text, "referenceTaskIds": "tsk_a"})),
3625 "referenceTaskIds",
3626 ),
3627 ] {
3628 let message = invalid_params(¶ms);
3629 assert!(message.contains(expected), "{params}: {message}");
3630 }
3631 }
3632
3633 #[test]
3634 fn read_send_params_caps_distinct_references() {
3635 let ids = |n: usize| -> Vec<String> { (0..n).map(|i| format!("tsk_{i}")).collect() };
3636 let with = |ids: Vec<String>| {
3637 params(serde_json::json!({"messageId": "m", "role": "ROLE_USER",
3638 "parts": [{"text": "a"}], "referenceTaskIds": ids}))
3639 };
3640 let mut sixteen_twice = ids(MAX_REFERENCE_TASK_IDS);
3641 sixteen_twice.extend(ids(MAX_REFERENCE_TASK_IDS));
3642 assert_eq!(
3643 read_send_params(&with(sixteen_twice))
3644 .unwrap()
3645 .reference_task_ids,
3646 ids(MAX_REFERENCE_TASK_IDS)
3647 );
3648 let message = invalid_params(&with(ids(MAX_REFERENCE_TASK_IDS + 1)));
3649 assert!(message.contains("names 17 tasks"), "{message}");
3650 }
3651
3652 #[test]
3655 fn read_send_params_refuses_a_push_notification_config() {
3656 let message = user(serde_json::json!([{"text": "hi"}]));
3657 let asking = serde_json::json!({"message": message,
3658 "configuration": {"taskPushNotificationConfig": {"url": "https://example.com/hook"}}});
3659 assert_eq!(
3660 read_send_params(&asking),
3661 Err(MessageRefusal::PushNotificationNotSupported)
3662 );
3663 let answer = serde_json::to_value(
3664 MessageRefusal::PushNotificationNotSupported.into_response(Value::from(1)),
3665 )
3666 .unwrap();
3667 assert_eq!(answer["error"]["code"], -32003);
3668 assert_eq!(
3669 answer["error"]["message"],
3670 "push notifications are not supported by this agent"
3671 );
3672 assert_eq!(
3673 answer["error"]["data"][0],
3674 serde_json::json!({"@type": "type.googleapis.com/google.rpc.ErrorInfo",
3675 "reason": "PUSH_NOTIFICATION_NOT_SUPPORTED", "domain": "a2a-protocol.org",
3676 "metadata": {}})
3677 );
3678 for configuration in [
3679 serde_json::json!({"taskPushNotificationConfig": null}),
3680 serde_json::json!({"acceptedOutputModes": ["text/plain"], "historyLength": 2}),
3681 serde_json::json!("ignored"),
3682 ] {
3683 let params = serde_json::json!({"message": message, "configuration": configuration});
3684 assert!(read_send_params(¶ms).is_ok(), "{params}");
3685 }
3686 }
3687
3688 fn with_metadata(metadata: Value, task_id: Option<&str>) -> Value {
3690 let mut message = user(serde_json::json!([{"text": "a"}]));
3691 message["metadata"] = metadata;
3692 if let Some(task_id) = task_id {
3693 message["taskId"] = Value::from(task_id);
3694 }
3695 params(message)
3696 }
3697
3698 fn door(door: Value) -> Value {
3700 serde_json::json!({ DOOR_EXTENSION_URI: door })
3701 }
3702
3703 #[test]
3704 fn read_send_params_reads_forget_session_true() {
3705 let message = read_send_params(&with_metadata(
3706 door(serde_json::json!({"forgetSession": true})),
3707 None,
3708 ))
3709 .unwrap();
3710 assert!(message.forget_session);
3711 assert!(message.door_extension);
3712 }
3713
3714 #[test]
3715 fn read_send_params_reads_every_other_metadata_as_no_forget() {
3716 for (metadata, door_extension) in [
3717 (door(serde_json::json!({"forgetSession": false})), true),
3718 (door(serde_json::json!({})), true),
3719 (
3720 door(serde_json::json!({"other": 1, "forgetSession": false})),
3721 true,
3722 ),
3723 (serde_json::json!({}), false),
3724 (
3725 serde_json::json!({"https://example.com/x": {"forgetSession": true}}),
3726 false,
3727 ),
3728 (serde_json::json!({"forgetSession": true}), false),
3729 (serde_json::json!("not an object"), false),
3730 (
3731 serde_json::json!([door(serde_json::json!({"forgetSession": true}))]),
3732 false,
3733 ),
3734 (Value::Null, false),
3735 ] {
3736 let message = read_send_params(&with_metadata(metadata.clone(), None)).unwrap();
3737 assert!(!message.forget_session, "{metadata}");
3738 assert_eq!(message.door_extension, door_extension, "{metadata}");
3739 }
3740 let message = read_send_params(¶ms(user(serde_json::json!([{"text": "a"}])))).unwrap();
3741 assert!(
3742 !message.forget_session && !message.door_extension,
3743 "no metadata at all"
3744 );
3745 }
3746
3747 #[test]
3748 fn read_send_params_ignores_other_keys_beside_forget_session() {
3749 let message = read_send_params(&with_metadata(
3750 door(serde_json::json!({"forgetSession": true, "later": {"x": 1}})),
3751 None,
3752 ))
3753 .unwrap();
3754 assert!(message.forget_session);
3755 }
3756
3757 #[test]
3758 fn read_send_params_refuses_a_door_extension_value_that_is_not_an_object() {
3759 for value in [
3760 serde_json::json!(true),
3761 serde_json::json!("forget"),
3762 serde_json::json!([]),
3763 ] {
3764 assert_eq!(
3765 invalid_params(&with_metadata(door(value), None)),
3766 format!("message.metadata[\"{DOOR_EXTENSION_URI}\"] must be an object")
3767 );
3768 }
3769 }
3770
3771 #[test]
3772 fn read_send_params_refuses_a_forget_session_that_is_not_a_boolean() {
3773 for value in [serde_json::json!("true"), serde_json::json!(1), Value::Null] {
3774 assert_eq!(
3775 invalid_params(&with_metadata(
3776 door(serde_json::json!({"forgetSession": value})),
3777 None
3778 )),
3779 format!(
3780 "message.metadata[\"{DOOR_EXTENSION_URI}\"].forgetSession must be a boolean"
3781 )
3782 );
3783 }
3784 }
3785
3786 #[test]
3787 fn read_send_params_refuses_a_forget_on_a_message_that_continues_a_task() {
3788 assert_eq!(
3789 invalid_params(&with_metadata(
3790 door(serde_json::json!({"forgetSession": true})),
3791 Some("tsk_1")
3792 )),
3793 "forgetSession starts a new conversation, so it goes on a message that starts a task, \
3794 not on one that continues task tsk_1"
3795 );
3796 let message = read_send_params(&with_metadata(
3798 door(serde_json::json!({"forgetSession": false})),
3799 Some("tsk_1"),
3800 ))
3801 .unwrap();
3802 assert_eq!(message.task_id.as_deref(), Some("tsk_1"));
3803 assert!(!message.forget_session);
3804 }
3805
3806 #[test]
3809 fn read_send_params_checks_the_forget_field_in_order() {
3810 let mut message = with_metadata(door(Value::from(1)), None);
3812 message["message"]["referenceTaskIds"] = Value::from("tsk_a");
3813 assert!(invalid_params(&message).contains("referenceTaskIds"));
3814 let message = with_metadata(door(Value::from(1)), Some("tsk_1"));
3816 assert!(invalid_params(&message).ends_with("must be an object"));
3817 let message = with_metadata(
3819 door(serde_json::json!({"forgetSession": "yes"})),
3820 Some("tsk_1"),
3821 );
3822 assert!(invalid_params(&message).ends_with("must be a boolean"));
3823 let mut message = with_metadata(door(serde_json::json!({"forgetSession": 1})), None);
3825 message["message"]["parts"] = serde_json::json!([{"raw": "aGk="}]);
3826 assert!(invalid_params(&message).ends_with("must be a boolean"));
3827 let mut message = with_metadata(door(serde_json::json!({"forgetSession": true})), None);
3829 message["message"]["parts"] = serde_json::json!([{"raw": "aGk="}]);
3830 assert!(matches!(
3831 read_send_params(&message),
3832 Err(MessageRefusal::ContentTypeNotSupported { .. })
3833 ));
3834 let refusal = read_send_params(&with_metadata(door(Value::from(1)), None)).unwrap_err();
3836 let answer = wire(&refusal.into_response(Value::from(3)));
3837 assert_eq!(answer["error"]["code"], -32602);
3838 assert_eq!(
3839 answer["error"]["message"],
3840 format!("Invalid params: message.metadata[\"{DOOR_EXTENSION_URI}\"] must be an object")
3841 );
3842 }
3843
3844 fn configured(configuration: Value) -> Value {
3846 serde_json::json!({
3847 "message": user(serde_json::json!([{"text": "a"}])),
3848 "configuration": configuration,
3849 })
3850 }
3851
3852 #[test]
3853 fn read_send_params_reads_return_immediately_true_only_for_true() {
3854 let immediately = read_send_params(&configured(serde_json::json!({
3855 "returnImmediately": true,
3856 })))
3857 .unwrap();
3858 assert!(immediately.return_immediately);
3859 for params in [
3860 params(user(serde_json::json!([{"text": "a"}]))),
3861 configured(Value::Null),
3862 configured(serde_json::json!({})),
3863 configured(serde_json::json!({"returnImmediately": null})),
3864 configured(serde_json::json!({"returnImmediately": false})),
3865 configured(serde_json::json!({"historyLength": 2})),
3866 configured(serde_json::json!("ignored")),
3867 ] {
3868 assert!(
3869 !read_send_params(¶ms).unwrap().return_immediately,
3870 "{params}"
3871 );
3872 }
3873 }
3874
3875 #[test]
3876 fn read_send_params_refuses_a_return_immediately_that_is_not_a_boolean() {
3877 for value in [
3878 Value::from("yes"),
3879 Value::from(1),
3880 serde_json::json!({}),
3881 serde_json::json!([true]),
3882 ] {
3883 let params = configured(serde_json::json!({ "returnImmediately": value }));
3884 assert_eq!(
3885 invalid_params(¶ms),
3886 "configuration.returnImmediately must be a boolean",
3887 "{params}"
3888 );
3889 let answer = wire(
3890 &read_send_params(¶ms)
3891 .unwrap_err()
3892 .into_response(1.into()),
3893 );
3894 assert_eq!(answer["error"]["code"], -32602);
3895 assert_eq!(
3896 answer["error"]["message"],
3897 "Invalid params: configuration.returnImmediately must be a boolean"
3898 );
3899 }
3900 }
3901
3902 #[test]
3905 fn read_send_params_checks_return_immediately_in_order() {
3906 let mut message = with_metadata(door(serde_json::json!({"forgetSession": 1})), None);
3907 message["configuration"] = serde_json::json!({"returnImmediately": "yes"});
3908 assert!(invalid_params(&message).contains("forgetSession"));
3909
3910 let mut message = configured(serde_json::json!({
3911 "returnImmediately": "yes",
3912 "taskPushNotificationConfig": {"url": "https://example.com/hook"},
3913 }));
3914 message["message"]["parts"] = serde_json::json!([{"raw": "aGk="}]);
3915 assert!(invalid_params(&message).contains("returnImmediately"));
3916
3917 message["configuration"]["returnImmediately"] = Value::Bool(true);
3918 assert!(matches!(
3919 read_send_params(&message),
3920 Err(MessageRefusal::ContentTypeNotSupported { .. })
3921 ));
3922 }
3923
3924 #[test]
3926 fn read_send_params_refuses_the_first_file_part_with_content_type_not_supported() {
3927 assert_eq!(
3928 read_send_params(¶ms(user(serde_json::json!([
3929 {"text": "a"},
3930 {"url": "https://example.com/x.pdf", "mediaType": "application/pdf"},
3931 {"raw": "aGk="},
3932 ])))),
3933 Err(MessageRefusal::ContentTypeNotSupported {
3934 part_index: 1,
3935 kind: "url",
3936 media_type: Some("application/pdf".to_string()),
3937 })
3938 );
3939 let refusal =
3940 read_send_params(¶ms(user(serde_json::json!([{"raw": "aGk="}])))).unwrap_err();
3941 let answer = wire(&refusal.into_response(Value::from(3)));
3942 assert_eq!(answer["error"]["code"], -32005);
3943 let info = &answer["error"]["data"][0];
3944 assert_eq!(info["reason"], "CONTENT_TYPE_NOT_SUPPORTED");
3945 assert_eq!(info["metadata"], serde_json::json!({"partIndex": "0"}));
3946 invalid_params(¶ms(user(
3948 serde_json::json!([{"raw": "aGk="}, {"text": 1}]),
3949 )));
3950 }
3951
3952 fn rendered(parts: Value) -> String {
3953 read_send_params(¶ms(user(parts))).unwrap().agent_text()
3954 }
3955
3956 #[test]
3957 fn agent_text_of_text_parts_is_the_text_joined_by_newlines() {
3958 assert_eq!(
3959 rendered(
3960 serde_json::json!([{"text": "one"}, {"text": "two", "mediaType": "text/markdown"}])
3961 ),
3962 "one\ntwo"
3963 );
3964 }
3965
3966 #[test]
3967 fn agent_text_fences_a_data_part_beyond_its_longest_backtick_run() {
3968 assert_eq!(
3969 rendered(serde_json::json!([{"text": "summarise"}, {"data": {"rows": [1, 2]}}])),
3970 "summarise\n```data\n{\n \"rows\": [\n 1,\n 2\n ]\n}\n```"
3971 );
3972 let three =
3973 rendered(serde_json::json!([{"data": "a ``` b", "mediaType": "application/json"}]));
3974 assert_eq!(
3975 three,
3976 "````data media-type=application/json\n\"a ``` b\"\n````"
3977 );
3978 let five = rendered(serde_json::json!([{"data": {"x": "`````"}}]));
3979 assert!(five.starts_with("``````data\n"), "{five}");
3980 assert!(five.ends_with("\n``````"), "{five}");
3981 assert_eq!(
3982 rendered(serde_json::json!([{"data": null}])),
3983 "```data\nnull\n```"
3984 );
3985 }
3986
3987 #[test]
3988 fn agent_text_sanitises_the_fence_label() {
3989 assert_eq!(
3990 rendered(serde_json::json!([{"data": 1, "mediaType": "a b=`c",
3991 "filename": "my\tfile.json"}])),
3992 "```data media-type=a_b__c filename=my_file.json\n1\n```"
3993 );
3994 }
3995
3996 #[test]
3997 fn a_reference_block_names_each_task_as_it_stands() {
3998 let mut r = TaskRegistry::new(8, TaskAcceptance::Queue);
3999 for id in ["tsk_a", "tsk_b", "tsk_c", "tsk_e"] {
4000 r.enqueue(id, "ctx_001");
4001 }
4002 r.start_task("tsk_a".to_string(), "ctx_001".to_string(), TaskLane::Bg);
4003 r.finish_task(TaskState::Completed);
4004 r.record_ending("tsk_a", "done", Some("four ```"), false, &[], &[]);
4005 r.start_task("tsk_b".to_string(), "ctx_001".to_string(), TaskLane::Bg);
4006 r.finish_task(TaskState::Failed);
4007 r.record_ending("tsk_b", "the driver\nfailed", None, false, &[], &[]);
4008 r.start_task("tsk_c".to_string(), "ctx_001".to_string(), TaskLane::Bg);
4009 r.record_owner("tsk_e", "member:reviewer");
4010 let ids: Vec<String> = ["tsk_a", "tsk_b", "tsk_c", "tsk_d", "tsk_e"]
4011 .map(str::to_string)
4012 .to_vec();
4013
4014 assert_eq!(
4015 r.reference_block(&ids, |_| true).unwrap(),
4016 "Referenced tasks:\n\
4017 - tsk_a: completed\n````response\nfour ```\n````\n\
4018 - tsk_b: failed: the driver failed\n\
4019 - tsk_c: working\n\
4020 - tsk_d: not a task this capsule holds\n\
4021 - tsk_e: submitted"
4022 );
4023 let member = |task_id: &str| r.owner(task_id) == Some("member:planner");
4024 let block = r.reference_block(&ids[4..], member).unwrap();
4025 assert_eq!(
4026 block,
4027 "Referenced tasks:\n- tsk_e: not a task this capsule holds"
4028 );
4029 assert_eq!(r.reference_block(&[], |_| true), None);
4030 }
4031
4032 #[test]
4033 fn user_message_is_a_v1_message_from_the_user() {
4034 assert_eq!(
4035 user_message("msg_1", Some("ctx_1"), "hello"),
4036 serde_json::json!({
4037 "messageId": "msg_1",
4038 "contextId": "ctx_1",
4039 "role": "ROLE_USER",
4040 "parts": [{"text": "hello", "mediaType": "text/plain"}],
4041 })
4042 );
4043 assert!(user_message("msg_1", None, "hello")
4044 .get("contextId")
4045 .is_none());
4046 assert!(read_send_params(¶ms(user_message("msg_1", None, "hello"))).is_ok());
4047 }
4048
4049 #[test]
4050 fn send_message_params_answer_at_acceptance() {
4051 let message = user_message("msg_1", None, "hello");
4052 let params = send_message_params(message.clone());
4053 assert_eq!(
4054 params,
4055 serde_json::json!({
4056 "message": message,
4057 "configuration": {"returnImmediately": true},
4058 })
4059 );
4060 assert!(read_send_params(¶ms).unwrap().return_immediately);
4061 }
4062
4063 #[test]
4064 fn send_message_result_is_exactly_one_member_of_the_oneof() {
4065 let task = serde_json::json!({"id": "tsk_1", "contextId": "c",
4066 "status": {"state": "TASK_STATE_SUBMITTED"}});
4067 assert!(matches!(
4068 SendMessageResult::from_result(&serde_json::json!({ "task": task })),
4069 Ok(SendMessageResult::Task(task)) if task.id == "tsk_1"
4070 ));
4071 assert!(matches!(
4072 SendMessageResult::from_result(&serde_json::json!({"message": {"messageId": "m"}})),
4073 Ok(SendMessageResult::Message(_))
4074 ));
4075 for refused in [
4076 serde_json::json!({}),
4077 serde_json::json!({"task": task, "message": {}}),
4078 task.clone(),
4079 serde_json::json!({"task": {"id": "tsk_1", "contextId": "c",
4080 "status": {"state": "submitted"}}}),
4081 ] {
4082 assert!(
4083 SendMessageResult::from_result(&refused).is_err(),
4084 "{refused}"
4085 );
4086 }
4087 }
4088}