use serde::{Deserialize, Serialize};
pub const RELATED_TASK_META_KEY: &str = "io.modelcontextprotocol/related-task";
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaskStatus {
#[default]
Working,
InputRequired,
Completed,
Failed,
Cancelled,
}
impl std::fmt::Display for TaskStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Working => write!(f, "working"),
Self::InputRequired => write!(f, "input_required"),
Self::Completed => write!(f, "completed"),
Self::Failed => write!(f, "failed"),
Self::Cancelled => write!(f, "cancelled"),
}
}
}
impl TaskStatus {
pub fn is_terminal(&self) -> bool {
matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
}
pub fn can_transition_to(&self, next: &Self) -> bool {
if self == next {
return false;
}
match self {
Self::Working => matches!(
next,
Self::InputRequired | Self::Completed | Self::Failed | Self::Cancelled
),
Self::InputRequired => matches!(
next,
Self::Working | Self::Completed | Self::Failed | Self::Cancelled
),
Self::Completed | Self::Failed | Self::Cancelled => false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum TaskPollDecision {
Terminal {
status: TaskStatus,
},
InProgress {
poll_hint: Option<u64>,
},
InputRequired,
}
pub const DEFAULT_POLL_MS: u64 = 1000;
pub const MIN_POLL_MS: u64 = 50;
pub fn resolve_poll_interval(caller_override: Option<u64>, hint: Option<u64>) -> u64 {
caller_override
.or(hint)
.unwrap_or(DEFAULT_POLL_MS)
.max(MIN_POLL_MS)
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[non_exhaustive]
#[serde(rename_all = "camelCase")]
pub struct Task {
pub task_id: String,
pub status: TaskStatus,
pub ttl: Option<u64>,
pub created_at: String,
pub last_updated_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub poll_interval: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status_message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub diagnostic_detail: Option<String>,
}
impl Task {
pub fn new(task_id: impl Into<String>, status: TaskStatus) -> Self {
Self {
task_id: task_id.into(),
status,
ttl: None,
created_at: String::new(),
last_updated_at: String::new(),
poll_interval: None,
status_message: None,
diagnostic_detail: None,
}
}
pub fn with_ttl(mut self, ttl: u64) -> Self {
self.ttl = Some(ttl);
self
}
pub fn with_timestamps(
mut self,
created_at: impl Into<String>,
last_updated_at: impl Into<String>,
) -> Self {
self.created_at = created_at.into();
self.last_updated_at = last_updated_at.into();
self
}
pub fn with_poll_interval(mut self, interval: u64) -> Self {
self.poll_interval = Some(interval);
self
}
pub fn with_status_message(mut self, message: impl Into<String>) -> Self {
self.status_message = Some(message.into());
self
}
pub fn with_diagnostic_detail(mut self, detail: impl Into<String>) -> Self {
self.diagnostic_detail = Some(detail.into());
self
}
pub fn poll_decision(&self) -> TaskPollDecision {
match self.status {
TaskStatus::Working => TaskPollDecision::InProgress {
poll_hint: self.poll_interval,
},
TaskStatus::InputRequired => TaskPollDecision::InputRequired,
TaskStatus::Completed | TaskStatus::Failed | TaskStatus::Cancelled => {
TaskPollDecision::Terminal {
status: self.status,
}
},
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[non_exhaustive]
#[serde(rename_all = "camelCase")]
pub struct TaskCreationParams {
pub ttl: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub poll_interval: Option<u64>,
}
impl TaskCreationParams {
pub fn new() -> Self {
Self::default()
}
pub fn with_ttl(mut self, ttl: u64) -> Self {
self.ttl = Some(ttl);
self
}
pub fn with_poll_interval(mut self, interval: u64) -> Self {
self.poll_interval = Some(interval);
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RelatedTaskMetadata {
pub task_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
#[serde(rename_all = "camelCase")]
pub struct TaskMetadata {
pub task_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub poll_interval: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_poll_duration_secs: Option<u64>,
}
impl TaskMetadata {
pub fn new(task_id: impl Into<String>) -> Self {
Self {
task_id: task_id.into(),
poll_interval: None,
max_poll_duration_secs: None,
}
}
pub fn with_poll_interval(mut self, interval_ms: u64) -> Self {
self.poll_interval = Some(interval_ms);
self
}
pub fn with_max_poll_duration_secs(mut self, secs: u64) -> Self {
self.max_poll_duration_secs = Some(secs);
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
#[serde(rename_all = "camelCase")]
pub struct CreateTaskResult {
pub task: Task,
}
impl CreateTaskResult {
pub fn new(task: Task) -> Self {
Self { task }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TaskStatusNotification {
pub task: Task,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GetTaskRequest {
pub task_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
#[serde(rename_all = "camelCase")]
pub struct GetTaskResult {
pub task: Task,
}
impl GetTaskResult {
pub fn new(task: Task) -> Self {
Self { task }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GetTaskPayloadRequest {
pub task_id: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ListTasksRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub cursor: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[non_exhaustive]
#[serde(rename_all = "camelCase")]
pub struct ListTasksResult {
pub tasks: Vec<Task>,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
}
impl ListTasksResult {
pub fn new(tasks: Vec<Task>) -> Self {
Self {
tasks,
next_cursor: None,
}
}
pub fn with_next_cursor(mut self, cursor: impl Into<String>) -> Self {
self.next_cursor = Some(cursor.into());
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CancelTaskRequest {
pub task_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
#[serde(rename_all = "camelCase")]
pub struct CancelTaskResult {
pub task: Task,
}
impl CancelTaskResult {
pub fn new(task: Task) -> Self {
Self { task }
}
}
pub const EXT_TASKS_SCHEMA_COMMIT: &str = "2c1425d9a288b9b1f489430fe1e00bb392b47e48";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
#[serde(rename_all = "camelCase")]
pub struct TaskV2 {
pub task_id: String,
pub status: TaskStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub status_message: Option<String>,
pub created_at: String,
pub last_updated_at: String,
pub ttl_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub poll_interval_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub diagnostic_detail: Option<String>,
}
impl TaskV2 {
pub fn from_v1(task: &Task) -> Self {
Self {
task_id: task.task_id.clone(),
status: task.status,
status_message: task.status_message.clone(),
created_at: task.created_at.clone(),
last_updated_at: task.last_updated_at.clone(),
ttl_ms: task.ttl,
poll_interval_ms: task.poll_interval,
diagnostic_detail: task.diagnostic_detail.clone(),
}
}
pub fn to_v1(&self) -> Task {
Task {
task_id: self.task_id.clone(),
status: self.status,
ttl: self.ttl_ms,
created_at: self.created_at.clone(),
last_updated_at: self.last_updated_at.clone(),
poll_interval: self.poll_interval_ms,
status_message: self.status_message.clone(),
diagnostic_detail: self.diagnostic_detail.clone(),
}
}
pub fn new(
task_id: impl Into<String>,
status: TaskStatus,
created_at: impl Into<String>,
last_updated_at: impl Into<String>,
ttl_ms: Option<u64>,
) -> Self {
Self {
task_id: task_id.into(),
status,
status_message: None,
created_at: created_at.into(),
last_updated_at: last_updated_at.into(),
ttl_ms,
poll_interval_ms: None,
diagnostic_detail: None,
}
}
}
#[derive(Debug, Clone)]
pub enum TaskDetailV2 {
Working,
InputRequired {
input_requests: crate::types::mrtr::InputRequests,
},
Completed {
result: serde_json::Map<String, serde_json::Value>,
},
Failed {
error: serde_json::Map<String, serde_json::Value>,
},
Cancelled,
}
impl TaskDetailV2 {
pub fn status(&self) -> TaskStatus {
match self {
Self::Working => TaskStatus::Working,
Self::InputRequired { .. } => TaskStatus::InputRequired,
Self::Completed { .. } => TaskStatus::Completed,
Self::Failed { .. } => TaskStatus::Failed,
Self::Cancelled => TaskStatus::Cancelled,
}
}
pub fn wire_key(&self) -> Option<&'static str> {
match self {
Self::Working | Self::Cancelled => None,
Self::InputRequired { .. } => Some(DETAIL_KEY_INPUT_REQUESTS),
Self::Completed { .. } => Some(DETAIL_KEY_RESULT),
Self::Failed { .. } => Some(DETAIL_KEY_ERROR),
}
}
}
pub const DETAIL_KEY_INPUT_REQUESTS: &str = "inputRequests";
pub const DETAIL_KEY_RESULT: &str = "result";
pub const DETAIL_KEY_ERROR: &str = "error";
#[derive(Debug, Clone)]
pub struct DetailedTaskV2 {
base: TaskV2,
detail: TaskDetailV2,
}
impl DetailedTaskV2 {
pub fn new(base: TaskV2, detail: TaskDetailV2) -> Self {
let mut base = base;
base.status = detail.status();
Self { base, detail }
}
pub fn task(&self) -> &TaskV2 {
&self.base
}
pub fn detail(&self) -> &TaskDetailV2 {
&self.detail
}
pub fn to_wire_object(&self) -> serde_json::Map<String, serde_json::Value> {
let mut object = match serde_json::to_value(&self.base) {
Ok(serde_json::Value::Object(map)) => map,
_ => serde_json::Map::new(),
};
match &self.detail {
TaskDetailV2::Working | TaskDetailV2::Cancelled => {},
TaskDetailV2::InputRequired { input_requests } => {
object.insert(
DETAIL_KEY_INPUT_REQUESTS.to_string(),
serde_json::to_value(input_requests)
.unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new())),
);
},
TaskDetailV2::Completed { result } => {
object.insert(
DETAIL_KEY_RESULT.to_string(),
serde_json::Value::Object(result.clone()),
);
},
TaskDetailV2::Failed { error } => {
object.insert(
DETAIL_KEY_ERROR.to_string(),
serde_json::Value::Object(error.clone()),
);
},
}
object
}
pub fn from_wire_value(value: &serde_json::Value) -> Result<Self, String> {
let base: TaskV2 = serde_json::from_value(value.clone())
.map_err(|e| format!("not a v2 Task payload: {e}"))?;
let object = value
.as_object()
.ok_or_else(|| "a DetailedTask must be a JSON object".to_string())?;
let required_object = |key: &str| -> Result<serde_json::Map<String, _>, String> {
object
.get(key)
.and_then(serde_json::Value::as_object)
.cloned()
.ok_or_else(|| format!("a {} task requires an object `{key}`", base.status))
};
let detail = match base.status {
TaskStatus::Working => TaskDetailV2::Working,
TaskStatus::Cancelled => TaskDetailV2::Cancelled,
TaskStatus::Completed => TaskDetailV2::Completed {
result: required_object(DETAIL_KEY_RESULT)?,
},
TaskStatus::Failed => TaskDetailV2::Failed {
error: required_object(DETAIL_KEY_ERROR)?,
},
TaskStatus::InputRequired => TaskDetailV2::InputRequired {
input_requests: object
.get(DETAIL_KEY_INPUT_REQUESTS)
.ok_or_else(|| {
format!("an input_required task requires `{DETAIL_KEY_INPUT_REQUESTS}`")
})
.and_then(|v| {
serde_json::from_value(v.clone())
.map_err(|e| format!("malformed `{DETAIL_KEY_INPUT_REQUESTS}`: {e}"))
})?,
},
};
Ok(Self::new(base, detail))
}
}
impl Serialize for DetailedTaskV2 {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
self.to_wire_object().serialize(serializer)
}
}
impl<'de> Deserialize<'de> for DetailedTaskV2 {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = serde_json::Value::deserialize(deserializer)?;
Self::from_wire_value(&value).map_err(serde::de::Error::custom)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn task_status_serialization() {
assert_eq!(
serde_json::to_value(TaskStatus::Working).unwrap(),
"working"
);
assert_eq!(
serde_json::to_value(TaskStatus::InputRequired).unwrap(),
"input_required"
);
assert_eq!(
serde_json::to_value(TaskStatus::Completed).unwrap(),
"completed"
);
assert_eq!(serde_json::to_value(TaskStatus::Failed).unwrap(), "failed");
assert_eq!(
serde_json::to_value(TaskStatus::Cancelled).unwrap(),
"cancelled"
);
}
#[test]
fn task_roundtrip() {
let task = Task::new("t-123", TaskStatus::Working)
.with_timestamps("2025-11-25T00:00:00Z", "2025-11-25T00:01:00Z")
.with_ttl(60000)
.with_poll_interval(5000)
.with_status_message("Processing...");
let json = serde_json::to_value(&task).unwrap();
assert_eq!(json["taskId"], "t-123");
assert_eq!(json["status"], "working");
assert_eq!(json["ttl"], 60000);
assert_eq!(json["createdAt"], "2025-11-25T00:00:00Z");
assert_eq!(json["pollInterval"], 5000);
let roundtrip: Task = serde_json::from_value(json).unwrap();
assert_eq!(roundtrip.task_id, "t-123");
assert_eq!(roundtrip.status, TaskStatus::Working);
}
#[test]
fn create_task_result_roundtrip() {
let result = CreateTaskResult::new(
Task::new("t-456", TaskStatus::Completed)
.with_timestamps("2025-11-25T00:00:00Z", "2025-11-25T00:05:00Z"),
);
let json = serde_json::to_value(&result).unwrap();
assert_eq!(json["task"]["taskId"], "t-456");
assert_eq!(json["task"]["status"], "completed");
let roundtrip: CreateTaskResult = serde_json::from_value(json).unwrap();
assert_eq!(roundtrip.task.status, TaskStatus::Completed);
}
#[test]
fn task_ts_format_interop() {
let ts_json = json!({
"taskId": "task-abc",
"status": "input_required",
"createdAt": "2025-11-25T12:00:00.000Z",
"lastUpdatedAt": "2025-11-25T12:01:00.000Z",
"pollInterval": 3000,
"statusMessage": "Waiting for user input"
});
let task: Task = serde_json::from_value(ts_json).unwrap();
assert_eq!(task.task_id, "task-abc");
assert_eq!(task.status, TaskStatus::InputRequired);
assert_eq!(task.poll_interval, Some(3000));
}
#[test]
fn task_metadata_serde_round_trip() {
let meta = TaskMetadata {
task_id: "t1".to_string(),
poll_interval: Some(5000),
max_poll_duration_secs: None,
};
let json = serde_json::to_value(&meta).unwrap();
assert_eq!(json["taskId"], "t1");
assert_eq!(json["pollInterval"], 5000);
assert!(
json.get("maxPollDurationSecs").is_none(),
"maxPollDurationSecs should be omitted when None"
);
let roundtrip: TaskMetadata = serde_json::from_value(json).unwrap();
assert_eq!(roundtrip.task_id, "t1");
assert_eq!(roundtrip.poll_interval, Some(5000));
assert_eq!(roundtrip.max_poll_duration_secs, None);
}
#[test]
fn task_metadata_minimal_shape_deserializes() {
let meta: TaskMetadata = serde_json::from_value(json!({ "taskId": "t1" })).unwrap();
assert_eq!(meta.task_id, "t1");
assert_eq!(meta.poll_interval, None);
assert_eq!(meta.max_poll_duration_secs, None);
}
#[test]
fn related_task_meta_key_value() {
assert_eq!(
RELATED_TASK_META_KEY,
"io.modelcontextprotocol/related-task"
);
}
#[test]
fn task_status_is_terminal() {
assert!(!TaskStatus::Working.is_terminal());
assert!(!TaskStatus::InputRequired.is_terminal());
assert!(TaskStatus::Completed.is_terminal());
assert!(TaskStatus::Failed.is_terminal());
assert!(TaskStatus::Cancelled.is_terminal());
}
#[test]
fn task_status_can_transition_to() {
assert!(TaskStatus::Working.can_transition_to(&TaskStatus::InputRequired));
assert!(TaskStatus::Working.can_transition_to(&TaskStatus::Completed));
assert!(TaskStatus::Working.can_transition_to(&TaskStatus::Failed));
assert!(TaskStatus::Working.can_transition_to(&TaskStatus::Cancelled));
assert!(TaskStatus::InputRequired.can_transition_to(&TaskStatus::Working));
assert!(TaskStatus::InputRequired.can_transition_to(&TaskStatus::Completed));
assert!(TaskStatus::InputRequired.can_transition_to(&TaskStatus::Failed));
assert!(TaskStatus::InputRequired.can_transition_to(&TaskStatus::Cancelled));
}
#[test]
fn task_status_self_transition_rejected() {
assert!(!TaskStatus::Working.can_transition_to(&TaskStatus::Working));
assert!(!TaskStatus::InputRequired.can_transition_to(&TaskStatus::InputRequired));
assert!(!TaskStatus::Completed.can_transition_to(&TaskStatus::Completed));
assert!(!TaskStatus::Failed.can_transition_to(&TaskStatus::Failed));
assert!(!TaskStatus::Cancelled.can_transition_to(&TaskStatus::Cancelled));
}
#[test]
fn task_status_terminal_rejects_all() {
for terminal in [
TaskStatus::Completed,
TaskStatus::Failed,
TaskStatus::Cancelled,
] {
for target in [
TaskStatus::Working,
TaskStatus::InputRequired,
TaskStatus::Completed,
TaskStatus::Failed,
TaskStatus::Cancelled,
] {
assert!(
!terminal.can_transition_to(&target),
"{terminal:?} should not transition to {target:?}"
);
}
}
}
#[test]
fn task_ttl_null_serialization() {
let task = Task::new("test-null-ttl", TaskStatus::Working)
.with_timestamps("2025-11-25T00:00:00Z", "2025-11-25T00:01:00Z");
let json = serde_json::to_value(&task).unwrap();
assert!(json.get("ttl").is_some(), "ttl must be present");
assert!(json["ttl"].is_null(), "ttl must be null when None");
assert!(
json.get("pollInterval").is_none(),
"pollInterval should be omitted when None"
);
}
#[test]
fn task_ttl_present_serialization() {
let task = Task::new("test-present-ttl", TaskStatus::Working)
.with_timestamps("2025-11-25T00:00:00Z", "2025-11-25T00:01:00Z")
.with_ttl(60000);
let json = serde_json::to_value(&task).unwrap();
assert_eq!(json["ttl"], 60000);
}
#[test]
fn task_diagnostic_detail_absent_when_none() {
let task = Task::new("t-diag-none", TaskStatus::Working)
.with_timestamps("2025-11-25T00:00:00Z", "2025-11-25T00:01:00Z");
assert_eq!(task.diagnostic_detail, None);
let json = serde_json::to_value(&task).unwrap();
assert!(
json.get("diagnosticDetail").is_none(),
"diagnosticDetail must be omitted from JSON when None"
);
}
#[test]
fn task_diagnostic_detail_round_trip() {
let task = Task::new("t-diag-some", TaskStatus::Failed)
.with_timestamps("2025-11-25T00:00:00Z", "2025-11-25T00:01:00Z")
.with_status_message("The AI service was temporarily unavailable")
.with_diagnostic_detail(
"step=call_tool op=propose_schema url=https://api.example/v1/x error=timeout",
);
let json = serde_json::to_value(&task).unwrap();
assert_eq!(
json["diagnosticDetail"],
"step=call_tool op=propose_schema url=https://api.example/v1/x error=timeout"
);
assert_eq!(
json["statusMessage"],
"The AI service was temporarily unavailable"
);
let roundtrip: Task = serde_json::from_value(json).unwrap();
assert_eq!(
roundtrip.diagnostic_detail.as_deref(),
Some("step=call_tool op=propose_schema url=https://api.example/v1/x error=timeout")
);
assert_eq!(
roundtrip.status_message.as_deref(),
Some("The AI service was temporarily unavailable")
);
}
#[test]
fn task_diagnostic_detail_consumer_tolerance() {
let wire_json = json!({
"taskId": "task-tolerant",
"status": "failed",
"ttl": null,
"createdAt": "2025-11-25T12:00:00.000Z",
"lastUpdatedAt": "2025-11-25T12:01:00.000Z",
"statusMessage": "The AI service was temporarily unavailable",
"diagnosticDetail": "step=call_tool op=propose_schema error=timeout",
"someFutureUnknownField": { "nested": "value" }
});
let task: Task = serde_json::from_value(wire_json)
.expect("Task must tolerate diagnosticDetail + an unrelated unknown field");
assert_eq!(task.task_id, "task-tolerant");
assert_eq!(task.status, TaskStatus::Failed);
assert_eq!(
task.diagnostic_detail.as_deref(),
Some("step=call_tool op=propose_schema error=timeout")
);
assert_eq!(
task.status_message.as_deref(),
Some("The AI service was temporarily unavailable")
);
}
#[test]
fn task_status_display() {
assert_eq!(TaskStatus::Working.to_string(), "working");
assert_eq!(TaskStatus::InputRequired.to_string(), "input_required");
assert_eq!(TaskStatus::Completed.to_string(), "completed");
assert_eq!(TaskStatus::Failed.to_string(), "failed");
assert_eq!(TaskStatus::Cancelled.to_string(), "cancelled");
}
const ALL_STATUSES: [TaskStatus; 5] = [
TaskStatus::Working,
TaskStatus::InputRequired,
TaskStatus::Completed,
TaskStatus::Failed,
TaskStatus::Cancelled,
];
fn expected_decision(status: TaskStatus, poll_interval: Option<u64>) -> TaskPollDecision {
match status {
TaskStatus::Working => TaskPollDecision::InProgress {
poll_hint: poll_interval,
},
TaskStatus::InputRequired => TaskPollDecision::InputRequired,
TaskStatus::Completed | TaskStatus::Failed | TaskStatus::Cancelled => {
TaskPollDecision::Terminal { status }
},
}
}
#[test]
fn poll_decision_maps_every_status() {
let working = Task::new("t-w", TaskStatus::Working).with_poll_interval(2500);
assert_eq!(
working.poll_decision(),
TaskPollDecision::InProgress {
poll_hint: Some(2500)
}
);
let working_none = Task::new("t-wn", TaskStatus::Working);
assert_eq!(
working_none.poll_decision(),
TaskPollDecision::InProgress { poll_hint: None }
);
let waiting = Task::new("t-i", TaskStatus::InputRequired);
assert_eq!(waiting.poll_decision(), TaskPollDecision::InputRequired);
for status in [
TaskStatus::Completed,
TaskStatus::Failed,
TaskStatus::Cancelled,
] {
let task = Task::new("t-term", status);
assert_eq!(
task.poll_decision(),
TaskPollDecision::Terminal { status },
"{status:?} must classify as Terminal"
);
}
}
#[test]
fn poll_decision_covers_all_statuses_exhaustively() {
for status in ALL_STATUSES {
let task = Task::new("t-x", status).with_poll_interval(1234);
assert_eq!(
task.poll_decision(),
expected_decision(status, Some(1234)),
"poll_decision() drifted for {status:?}"
);
}
}
#[test]
fn resolve_poll_interval_precedence() {
assert_eq!(resolve_poll_interval(Some(200), Some(999)), 200);
assert_eq!(resolve_poll_interval(None, Some(300)), 300);
assert_eq!(resolve_poll_interval(None, None), DEFAULT_POLL_MS);
assert_eq!(resolve_poll_interval(None, None), 1000);
}
#[test]
fn resolve_poll_interval_floors_zero() {
assert_eq!(resolve_poll_interval(Some(0), None), MIN_POLL_MS);
assert_eq!(resolve_poll_interval(Some(0), None), 50);
assert_eq!(resolve_poll_interval(None, Some(10)), 50);
assert_eq!(resolve_poll_interval(None, Some(0)), 50);
}
proptest::proptest! {
#[test]
fn poll_decision_matches_expected_map(
status_idx in 0usize..ALL_STATUSES.len(),
poll_interval in proptest::option::of(proptest::prelude::any::<u64>()),
) {
let status = ALL_STATUSES[status_idx];
let mut task = Task::new("t-prop", status);
task.poll_interval = poll_interval;
proptest::prop_assert_eq!(
task.poll_decision(),
expected_decision(status, poll_interval)
);
}
#[test]
fn resolve_poll_interval_never_below_floor(
caller in proptest::option::of(proptest::prelude::any::<u64>()),
hint in proptest::option::of(proptest::prelude::any::<u64>()),
) {
proptest::prop_assert!(resolve_poll_interval(caller, hint) >= MIN_POLL_MS);
}
}
}
#[cfg(test)]
mod v2_projection_tests {
use super::*;
use serde_json::{json, Value};
const EXT_TASKS_SCHEMA_JSON: &str = include_str!("../../schema/vendored/ext-tasks/schema.json");
fn schema_required(def: &str) -> Vec<String> {
let schema: Value =
serde_json::from_str(EXT_TASKS_SCHEMA_JSON).expect("vendored schema parses");
let mut required: Vec<String> = schema["$defs"][def]["required"]
.as_array()
.unwrap_or_else(|| panic!("$defs.{def}.required is an array"))
.iter()
.map(|v| {
v.as_str()
.expect("a required entry is a string")
.to_string()
})
.collect();
required.sort();
required
}
fn sorted_keys(object: &serde_json::Map<String, Value>) -> Vec<String> {
let mut keys: Vec<String> = object.keys().cloned().collect();
keys.sort();
keys
}
fn minimal(status: TaskStatus) -> TaskV2 {
TaskV2::new(
"t-1",
status,
"2026-07-28T00:00:00Z",
"2026-07-28T00:00:01Z",
Some(60_000),
)
}
fn requests() -> crate::types::mrtr::InputRequests {
let mut map = crate::types::mrtr::InputRequests::new();
map.insert(
"roots".to_string(),
crate::types::mrtr::InputRequest::ListRoots,
);
map
}
#[test]
fn v2_projection_uses_the_renamed_ttl_ms_and_poll_interval_ms_keys() {
let mut task = minimal(TaskStatus::Working);
task.poll_interval_ms = Some(2500);
let raw = serde_json::to_string(&task).expect("serializes");
assert!(
raw.contains("\"ttlMs\":60000"),
"the v2 projection must spell `ttlMs`, got {raw}"
);
assert!(
raw.contains("\"pollIntervalMs\":2500"),
"the v2 projection must spell `pollIntervalMs`, got {raw}"
);
assert!(!raw.contains("\"ttl\":"), "v1 `ttl` leaked into v2: {raw}");
assert!(
!raw.contains("\"pollInterval\":"),
"v1 `pollInterval` leaked into v2: {raw}"
);
}
#[test]
fn v2_projection_serializes_a_none_ttl_as_an_explicit_null() {
let task = TaskV2::new(
"t-1",
TaskStatus::Working,
"2026-07-28T00:00:00Z",
"2026-07-28T00:00:01Z",
None,
);
let raw = serde_json::to_string(&task).expect("serializes");
assert!(
raw.contains("\"ttlMs\":null"),
"a None ttlMs must serialize as an explicit null, got {raw}"
);
}
#[test]
fn v2_projection_omits_a_none_poll_interval_ms_entirely() {
let task = minimal(TaskStatus::Working);
assert_eq!(task.poll_interval_ms, None);
let raw = serde_json::to_string(&task).expect("serializes");
assert!(
!raw.contains("pollIntervalMs"),
"a None pollIntervalMs is OPTIONAL and must be omitted, got {raw}"
);
}
#[test]
fn v2_projection_round_trips_a_v1_task_field_for_field() {
let original = Task::new("task-round-trip", TaskStatus::InputRequired)
.with_timestamps("2026-07-28T00:00:00Z", "2026-07-28T00:00:09Z")
.with_ttl(60_000)
.with_poll_interval(250)
.with_status_message("waiting on you")
.with_diagnostic_detail("step 3 of 7");
let round_tripped = TaskV2::from_v1(&original).to_v1();
assert_eq!(round_tripped.task_id, original.task_id);
assert_eq!(round_tripped.status, original.status);
assert_eq!(round_tripped.ttl, original.ttl);
assert_eq!(round_tripped.created_at, original.created_at);
assert_eq!(round_tripped.last_updated_at, original.last_updated_at);
assert_eq!(round_tripped.poll_interval, original.poll_interval);
assert_eq!(round_tripped.status_message, original.status_message);
assert_eq!(round_tripped.diagnostic_detail, original.diagnostic_detail);
assert_eq!(
serde_json::to_value(&round_tripped).expect("serializes"),
serde_json::to_value(&original).expect("serializes"),
);
}
#[test]
fn to_v1_maps_ttl_ms_and_poll_interval_ms_onto_the_v1_names() {
let mut v2 = TaskV2::new(
"task-renames",
TaskStatus::Working,
"2026-07-28T00:00:00Z",
"2026-07-28T00:00:01Z",
Some(30_000),
);
v2.poll_interval_ms = Some(750);
let v1 = v2.to_v1();
assert_eq!(v1.ttl, Some(30_000), "ttlMs must land on ttl");
assert_eq!(
v1.poll_interval,
Some(750),
"pollIntervalMs must land on pollInterval"
);
let raw = serde_json::to_string(&v1).expect("serializes");
assert!(
!raw.contains("ttlMs") && !raw.contains("pollIntervalMs"),
"a v2 key spelling leaked onto the v1 Task: {raw}"
);
}
#[test]
fn v2_projection_working_key_set_equals_the_schema_required_set() {
let detailed = DetailedTaskV2::new(minimal(TaskStatus::Working), TaskDetailV2::Working);
let object = detailed.to_wire_object();
assert_eq!(
sorted_keys(&object),
schema_required("WorkingTask"),
"a minimal working projection must emit EXACTLY the schema's \
WorkingTask.required set — no optional key is set on this fixture"
);
}
#[test]
fn v2_projection_working_key_set_grows_only_by_the_optional_keys_actually_set() {
let mut base = minimal(TaskStatus::Working);
base.poll_interval_ms = Some(1000);
base.status_message = Some("still going".to_string());
let object = DetailedTaskV2::new(base, TaskDetailV2::Working).to_wire_object();
let mut expected = schema_required("WorkingTask");
expected.push("pollIntervalMs".to_string());
expected.push("statusMessage".to_string());
expected.sort();
assert_eq!(sorted_keys(&object), expected);
}
#[test]
fn v2_projection_each_variant_emits_its_schema_required_set() {
let cases: Vec<(&str, TaskDetailV2)> = vec![
("WorkingTask", TaskDetailV2::Working),
("CancelledTask", TaskDetailV2::Cancelled),
(
"InputRequiredTask",
TaskDetailV2::InputRequired {
input_requests: requests(),
},
),
(
"CompletedTask",
TaskDetailV2::Completed {
result: json!({ "content": [] })
.as_object()
.expect("object")
.clone(),
},
),
(
"FailedTask",
TaskDetailV2::Failed {
error: json!({ "code": -32603, "message": "boom" })
.as_object()
.expect("object")
.clone(),
},
),
];
for (def, detail) in cases {
let object = DetailedTaskV2::new(minimal(TaskStatus::Working), detail).to_wire_object();
assert_eq!(
sorted_keys(&object),
schema_required(def),
"{def} must emit exactly its schema-required key set"
);
}
}
#[test]
fn v2_projection_detail_overrides_a_disagreeing_base_status() {
let detailed = DetailedTaskV2::new(
minimal(TaskStatus::Working),
TaskDetailV2::Failed {
error: json!({ "code": -32603 })
.as_object()
.expect("object")
.clone(),
},
);
assert_eq!(detailed.task().status, TaskStatus::Failed);
assert_eq!(detailed.to_wire_object()["status"], json!("failed"));
}
#[test]
fn v2_projection_status_and_detail_agree_for_every_variant() {
for detail in [
TaskDetailV2::Working,
TaskDetailV2::Cancelled,
TaskDetailV2::InputRequired {
input_requests: requests(),
},
TaskDetailV2::Completed {
result: serde_json::Map::new(),
},
TaskDetailV2::Failed {
error: serde_json::Map::new(),
},
] {
let expected = detail.status();
let object = DetailedTaskV2::new(minimal(TaskStatus::Working), detail).to_wire_object();
assert_eq!(
object["status"],
serde_json::to_value(expected).expect("status serializes")
);
}
}
#[test]
fn v2_projection_round_trips_through_from_wire_value() {
let original = DetailedTaskV2::new(
minimal(TaskStatus::Working),
TaskDetailV2::InputRequired {
input_requests: requests(),
},
);
let value = serde_json::to_value(&original).expect("serializes");
let decoded = DetailedTaskV2::from_wire_value(&value).expect("decodes");
assert_eq!(decoded.to_wire_object(), original.to_wire_object());
assert_eq!(
serde_json::to_string(&decoded).expect("re-serializes"),
serde_json::to_string(&original).expect("serializes"),
"a round trip must be byte-identical, not merely structurally equal"
);
}
#[test]
fn v2_projection_decode_rejects_a_variant_missing_its_required_key() {
for (status, key) in [
("completed", "result"),
("failed", "error"),
("input_required", "inputRequests"),
] {
let value = json!({
"taskId": "t-1",
"status": status,
"createdAt": "2026-07-28T00:00:00Z",
"lastUpdatedAt": "2026-07-28T00:00:01Z",
"ttlMs": null,
});
let err = DetailedTaskV2::from_wire_value(&value)
.err()
.unwrap_or_else(|| {
panic!("a {status} task carrying no {key} must be refused, not decoded")
});
assert!(
err.contains(key),
"the refusal must name the missing key `{key}`, got {err}"
);
}
}
#[test]
fn v2_projection_input_requests_key_matches_the_reserved_registry_key() {
assert_eq!(
DETAIL_KEY_INPUT_REQUESTS,
crate::types::mrtr::INPUT_REQUESTS_KEY
);
}
#[test]
fn v2_projection_from_v1_renames_ttl_and_poll_interval_without_touching_v1() {
let v1 = Task::new("t-1", TaskStatus::Working)
.with_timestamps("2026-07-28T00:00:00Z", "2026-07-28T00:00:01Z")
.with_ttl(60_000)
.with_poll_interval(2500)
.with_status_message("running");
let projected = TaskV2::from_v1(&v1);
assert_eq!(projected.ttl_ms, Some(60_000));
assert_eq!(projected.poll_interval_ms, Some(2500));
assert_eq!(projected.task_id, "t-1");
assert_eq!(projected.status_message.as_deref(), Some("running"));
let v1_raw = serde_json::to_string(&v1).expect("v1 serializes");
assert!(v1_raw.contains("\"ttl\":60000"), "{v1_raw}");
assert!(v1_raw.contains("\"pollInterval\":2500"), "{v1_raw}");
assert!(!v1_raw.contains("ttlMs"), "{v1_raw}");
assert!(!v1_raw.contains("pollIntervalMs"), "{v1_raw}");
}
#[test]
fn v2_projection_carries_the_pmcp_diagnostic_detail_extension() {
let v1 = Task::new("t-1", TaskStatus::Working)
.with_timestamps("2026-07-28T00:00:00Z", "2026-07-28T00:00:01Z")
.with_diagnostic_detail("step-3 timed out");
let raw = serde_json::to_string(&TaskV2::from_v1(&v1)).expect("serializes");
assert!(
raw.contains("\"diagnosticDetail\":\"step-3 timed out\""),
"{raw}"
);
}
}