Skip to main content

pmcp/types/
tasks.rs

1//! MCP Task protocol types (2025-11-25).
2//!
3//! This module contains the wire types for MCP Tasks as defined
4//! in the 2025-11-25 protocol version.
5
6use serde::{Deserialize, Serialize};
7
8/// Related task metadata key per MCP spec.
9pub const RELATED_TASK_META_KEY: &str = "io.modelcontextprotocol/related-task";
10
11/// Task status (5-value enum).
12#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum TaskStatus {
15    /// Task is actively being worked on
16    #[default]
17    Working,
18    /// Task requires user input to continue
19    InputRequired,
20    /// Task completed successfully
21    Completed,
22    /// Task failed
23    Failed,
24    /// Task was cancelled
25    Cancelled,
26}
27
28impl std::fmt::Display for TaskStatus {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        match self {
31            Self::Working => write!(f, "working"),
32            Self::InputRequired => write!(f, "input_required"),
33            Self::Completed => write!(f, "completed"),
34            Self::Failed => write!(f, "failed"),
35            Self::Cancelled => write!(f, "cancelled"),
36        }
37    }
38}
39
40impl TaskStatus {
41    /// Returns `true` if this status is terminal (no further transitions allowed).
42    ///
43    /// Terminal states are `Completed`, `Failed`, and `Cancelled`.
44    pub fn is_terminal(&self) -> bool {
45        matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
46    }
47
48    /// Returns `true` if transitioning from this status to `next` is valid.
49    ///
50    /// The MCP spec defines these valid transitions:
51    /// - `Working` -> `InputRequired`, `Completed`, `Failed`, `Cancelled`
52    /// - `InputRequired` -> `Working`, `Completed`, `Failed`, `Cancelled`
53    /// - Terminal states -> no transitions allowed
54    ///
55    /// Self-transitions (e.g., `Working` -> `Working`) are rejected per spec.
56    pub fn can_transition_to(&self, next: &Self) -> bool {
57        if self == next {
58            return false;
59        }
60
61        match self {
62            Self::Working => matches!(
63                next,
64                Self::InputRequired | Self::Completed | Self::Failed | Self::Cancelled
65            ),
66            Self::InputRequired => matches!(
67                next,
68                Self::Working | Self::Completed | Self::Failed | Self::Cancelled
69            ),
70            Self::Completed | Self::Failed | Self::Cancelled => false,
71        }
72    }
73}
74
75/// The classification of a polled [`Task`], derived purely from its
76/// already-deserialized [`TaskStatus`] — the single decision primitive every
77/// task poller consumes so the branch logic cannot drift.
78///
79/// Produced by [`Task::poll_decision`]. It answers the one question a poll loop
80/// asks each tick: *stop, ask the user, or sleep and poll again?* — without a
81/// network round-trip, a [`CallToolResult`](crate::types::CallToolResult)
82/// fetch, or any I/O. It is a pure, replay-deterministic function of the polled
83/// `Task`, so it is safe to call inside a memoized durable/replay step (D-01,
84/// D-03).
85///
86/// # Non-exhaustive
87///
88/// This enum is `#[non_exhaustive]` for future-proofing (D-04): adding a variant
89/// later is a non-breaking change, so external `match` sites must carry a
90/// wildcard `_ =>` arm. This is a distinct claim from [`TaskStatus`], which is
91/// deliberately **exhaustive** today (D-15): `#[non_exhaustive]` here does NOT
92/// imply that unknown or future wire statuses are handled gracefully at runtime.
93/// An unknown status fails at serde deserialization during `tasks/get`, BEFORE
94/// classification ever runs — `poll_decision()` only ever sees one of the five
95/// known `TaskStatus` values.
96///
97/// Unlike every other type in this module, `TaskPollDecision` intentionally
98/// derives neither `Serialize` nor `Deserialize`: it is a returned classifier
99/// value consumed in-process, never a wire type.
100#[derive(Debug, Clone, PartialEq, Eq)]
101#[non_exhaustive]
102pub enum TaskPollDecision {
103    /// The task has reached a terminal status ([`TaskStatus::Completed`],
104    /// [`TaskStatus::Failed`], or [`TaskStatus::Cancelled`]) and will not
105    /// transition further — the poll loop should stop.
106    ///
107    /// The classifier carries the terminal [`TaskStatus`] by value (it is
108    /// `Copy`) but deliberately does NOT carry the final
109    /// [`CallToolResult`](crate::types::CallToolResult): to retrieve the
110    /// result the caller still issues a **separate `tasks/result`** call
111    /// (D-06/D-16). This keeps classification a pure, I/O-free decision.
112    Terminal {
113        /// The terminal status that ended the task.
114        status: TaskStatus,
115    },
116    /// The task is still running ([`TaskStatus::Working`]) — the poll loop
117    /// should sleep and poll again.
118    ///
119    /// `poll_hint` is the raw server-reported `pollInterval` in **milliseconds**
120    /// ([`Task::poll_interval`]), passed through verbatim including `None`
121    /// (D-07). Feed it to [`resolve_poll_interval`] to obtain the concrete sleep
122    /// duration honoring the caller override, this hint, the default, and the
123    /// hot-loop floor.
124    InProgress {
125        /// The raw server-reported `pollInterval` in ms, verbatim (`None` when
126        /// the server suggested no interval).
127        poll_hint: Option<u64>,
128    },
129    /// The task is blocked on user input ([`TaskStatus::InputRequired`]).
130    ///
131    /// This is a unit variant carrying no payload (D-05): the caller already
132    /// holds the polled `Task`, and a blocking poller cannot supply the input,
133    /// so it must route to elicitation rather than continue spinning.
134    InputRequired,
135}
136
137/// The default poll interval, in **milliseconds**, used when neither the caller
138/// nor the server-reported `pollInterval` specifies one.
139///
140/// This is a **stable, supported public default** — the documented fallback in
141/// the poll-interval policy (1000 ms), not an internal tunable. Its value is a
142/// public API contract: changing it is a semver-relevant change, not a silent
143/// implementation detail. It is the single source of truth shared by
144/// [`resolve_poll_interval`] and every task poller (D-08).
145pub const DEFAULT_POLL_MS: u64 = 1000;
146
147/// The floor, in **milliseconds**, applied to any resolved poll interval so a
148/// zero or very small value cannot hot-spin the poll loop.
149///
150/// This is a **stable, supported public default** — the documented 50 ms
151/// hot-loop-protection floor in the poll-interval policy, not an internal
152/// tunable. Its value is a public API contract: changing it is a
153/// semver-relevant change. It is the single source of truth shared by
154/// [`resolve_poll_interval`] and the budget clamp in blocking pollers (D-08,
155/// T-105-01 mitigation).
156pub const MIN_POLL_MS: u64 = 50;
157
158/// Resolve the concrete poll interval, in **milliseconds**, from a caller
159/// override and a server-reported hint, applying the documented precedence and
160/// the hot-loop-protection floor.
161///
162/// Precedence: `caller_override` wins if present, else the server `hint`, else
163/// [`DEFAULT_POLL_MS`] (1000 ms); the result is then floored to at least
164/// [`MIN_POLL_MS`] (50 ms) so a zero or tiny value cannot busy-spin the poll
165/// loop (T-105-01 mitigation). This is the single source of truth for interval
166/// resolution, consumed by every task poller so the policy cannot drift (D-08).
167///
168/// Returns `u64` milliseconds — NOT a [`Duration`](std::time::Duration) — to
169/// stay symmetric with the `Option<u64>` inputs and consistent with
170/// [`Task::poll_interval`] and [`TaskMetadata::poll_interval`] (D-12). Callers
171/// wrap with `Duration::from_millis` at the sleep site.
172///
173/// # Examples
174///
175/// ```rust
176/// use pmcp::types::tasks::resolve_poll_interval;
177///
178/// // Caller override always wins.
179/// assert_eq!(resolve_poll_interval(Some(200), Some(999)), 200);
180/// // Server hint used when there is no override.
181/// assert_eq!(resolve_poll_interval(None, Some(300)), 300);
182/// // Falls back to the 1000 ms default when neither is set.
183/// assert_eq!(resolve_poll_interval(None, None), 1000);
184/// // A zero (or tiny) value is floored to 50 ms so it cannot hot-spin.
185/// assert_eq!(resolve_poll_interval(Some(0), None), 50);
186/// ```
187pub fn resolve_poll_interval(caller_override: Option<u64>, hint: Option<u64>) -> u64 {
188    caller_override
189        .or(hint)
190        .unwrap_or(DEFAULT_POLL_MS)
191        .max(MIN_POLL_MS)
192}
193
194/// A task resource representing an in-progress or completed operation.
195///
196/// # Backward Compatibility
197///
198/// This struct is `#[non_exhaustive]`. Use the constructor to remain
199/// forward-compatible:
200///
201/// ```rust
202/// use pmcp::types::tasks::{Task, TaskStatus};
203///
204/// let task = Task::new("t-123", TaskStatus::Working)
205///     .with_timestamps("2025-11-25T00:00:00Z", "2025-11-25T00:01:00Z")
206///     .with_ttl(60000)
207///     .with_poll_interval(5000)
208///     .with_status_message("Processing...");
209/// ```
210#[derive(Debug, Clone, Default, Serialize, Deserialize)]
211#[non_exhaustive]
212#[serde(rename_all = "camelCase")]
213pub struct Task {
214    /// Unique task identifier
215    pub task_id: String,
216    /// Current task status
217    pub status: TaskStatus,
218    /// Time-to-live in milliseconds. Required but nullable per MCP spec:
219    /// `None` serializes as `null` (unlimited TTL), `Some(ms)` as a number.
220    pub ttl: Option<u64>,
221    /// ISO 8601 creation timestamp
222    pub created_at: String,
223    /// ISO 8601 last-updated timestamp
224    pub last_updated_at: String,
225    /// Suggested polling interval in milliseconds
226    #[serde(skip_serializing_if = "Option::is_none")]
227    pub poll_interval: Option<u64>,
228    /// Human-readable status message
229    #[serde(skip_serializing_if = "Option::is_none")]
230    pub status_message: Option<String>,
231    /// Full operator-facing diagnostic detail (step ids, URLs, internal error
232    /// text) for this task.
233    ///
234    /// **PMCP EXTENSION — not an MCP-spec field (D-17).** The MCP `Task` type
235    /// carries a single [`status_message`](Task::status_message) voice, which
236    /// PMCP treats as the business-friendly, user-facing voice (D-17/D-18).
237    /// `diagnostic_detail` is a second, separate voice this SDK adds for
238    /// operator/developer consumption — full detail that would be
239    /// inappropriate to show a business user by default (see the
240    /// Information-Disclosure disposition on this field: producers MUST
241    /// redact secrets/tokens before setting it). Consuming UIs typically
242    /// render it behind an expandable "details" affordance.
243    ///
244    /// Because `Task` has no `deny_unknown_fields` (see the module's serde
245    /// round-trip and consumer-tolerance tests), existing/strict consumers
246    /// that don't know this field simply ignore the extra `diagnosticDetail`
247    /// key on the wire — this is additive and non-breaking. When absent
248    /// (`None`), it is skip-serialized, so callers that never set it produce
249    /// byte-identical JSON to before this field existed.
250    ///
251    /// **Future migration note:** if/when the MCP spec grows a `_meta`
252    /// extension slot on `Task` (mirroring `CallToolResult._meta` /
253    /// [`RELATED_TASK_META_KEY`]), this field is a candidate to migrate under
254    /// that slot instead of a top-level struct field, to keep `Task` itself
255    /// spec-pure. Until then, this is the pragmatic wire slot.
256    #[serde(skip_serializing_if = "Option::is_none")]
257    pub diagnostic_detail: Option<String>,
258}
259
260impl Task {
261    /// Create a task with the given ID and status.
262    ///
263    /// Timestamps default to empty strings (caller should set via
264    /// [`Task::with_timestamps`]). Optional fields default to `None`.
265    pub fn new(task_id: impl Into<String>, status: TaskStatus) -> Self {
266        Self {
267            task_id: task_id.into(),
268            status,
269            ttl: None,
270            created_at: String::new(),
271            last_updated_at: String::new(),
272            poll_interval: None,
273            status_message: None,
274            diagnostic_detail: None,
275        }
276    }
277
278    /// Set the time-to-live in milliseconds.
279    pub fn with_ttl(mut self, ttl: u64) -> Self {
280        self.ttl = Some(ttl);
281        self
282    }
283
284    /// Set both creation and last-updated timestamps.
285    pub fn with_timestamps(
286        mut self,
287        created_at: impl Into<String>,
288        last_updated_at: impl Into<String>,
289    ) -> Self {
290        self.created_at = created_at.into();
291        self.last_updated_at = last_updated_at.into();
292        self
293    }
294
295    /// Set the suggested polling interval in milliseconds.
296    pub fn with_poll_interval(mut self, interval: u64) -> Self {
297        self.poll_interval = Some(interval);
298        self
299    }
300
301    /// Set a human-readable status message.
302    pub fn with_status_message(mut self, message: impl Into<String>) -> Self {
303        self.status_message = Some(message.into());
304        self
305    }
306
307    /// Set the full operator-facing diagnostic detail (PMCP extension — D-17;
308    /// see the field doc comment on [`Task::diagnostic_detail`]).
309    pub fn with_diagnostic_detail(mut self, detail: impl Into<String>) -> Self {
310        self.diagnostic_detail = Some(detail.into());
311        self
312    }
313
314    /// Classify this already-polled task into a [`TaskPollDecision`] without any
315    /// I/O — the single decision primitive every task poller consumes.
316    ///
317    /// This is a pure, total function of the polled `Task`'s [`TaskStatus`]:
318    /// there is no `_` wildcard arm because `TaskStatus` is exhaustive (five
319    /// known variants), so the mapping cannot silently drift. Because it touches
320    /// nothing but the in-hand `Task`, it is replay-deterministic and safe to
321    /// call inside a memoized durable/replay step — provided the `tasks/get`
322    /// network call and its serde decode sit INSIDE the memoized step, so an
323    /// unknown/future status fails at deserialization before this runs (D-01,
324    /// D-04, D-14, D-15).
325    ///
326    /// Mapping:
327    /// - [`TaskStatus::Working`] → [`TaskPollDecision::InProgress`] carrying the
328    ///   task's `poll_interval` verbatim as `poll_hint` (D-07).
329    /// - [`TaskStatus::InputRequired`] → [`TaskPollDecision::InputRequired`].
330    /// - [`TaskStatus::Completed`] / [`TaskStatus::Failed`] /
331    ///   [`TaskStatus::Cancelled`] → [`TaskPollDecision::Terminal`] carrying the
332    ///   terminal status. The final
333    ///   [`CallToolResult`](crate::types::CallToolResult) is NOT fetched here —
334    ///   the caller issues a separate `tasks/result` call (D-06/D-16).
335    ///
336    /// # Examples
337    ///
338    /// ```rust
339    /// use pmcp::types::tasks::{Task, TaskStatus, TaskPollDecision};
340    ///
341    /// let task = Task::new("t-123", TaskStatus::Working).with_poll_interval(2000);
342    /// match task.poll_decision() {
343    ///     TaskPollDecision::InProgress { poll_hint } => assert_eq!(poll_hint, Some(2000)),
344    ///     // `TaskPollDecision` is `#[non_exhaustive]`, so external callers
345    ///     // need a wildcard arm.
346    ///     _ => panic!("a Working task must classify as InProgress"),
347    /// }
348    /// ```
349    pub fn poll_decision(&self) -> TaskPollDecision {
350        match self.status {
351            TaskStatus::Working => TaskPollDecision::InProgress {
352                poll_hint: self.poll_interval,
353            },
354            TaskStatus::InputRequired => TaskPollDecision::InputRequired,
355            TaskStatus::Completed | TaskStatus::Failed | TaskStatus::Cancelled => {
356                TaskPollDecision::Terminal {
357                    status: self.status,
358                }
359            },
360        }
361    }
362}
363
364/// Parameters for task creation (augments tools/call).
365#[derive(Debug, Clone, Default, Serialize, Deserialize)]
366#[non_exhaustive]
367#[serde(rename_all = "camelCase")]
368pub struct TaskCreationParams {
369    /// Time-to-live in milliseconds. Required but nullable per MCP spec:
370    /// `None` serializes as `null` (unlimited TTL), `Some(ms)` as a number.
371    pub ttl: Option<u64>,
372    /// Suggested polling interval in milliseconds
373    #[serde(skip_serializing_if = "Option::is_none")]
374    pub poll_interval: Option<u64>,
375}
376
377impl TaskCreationParams {
378    /// Create empty task creation parameters.
379    pub fn new() -> Self {
380        Self::default()
381    }
382
383    /// Set the time-to-live in milliseconds.
384    pub fn with_ttl(mut self, ttl: u64) -> Self {
385        self.ttl = Some(ttl);
386        self
387    }
388
389    /// Set the suggested polling interval in milliseconds.
390    pub fn with_poll_interval(mut self, interval: u64) -> Self {
391        self.poll_interval = Some(interval);
392        self
393    }
394}
395
396/// Task metadata for related-task references.
397#[derive(Debug, Clone, Serialize, Deserialize)]
398#[serde(rename_all = "camelCase")]
399pub struct RelatedTaskMetadata {
400    /// The referenced task ID
401    pub task_id: String,
402}
403
404/// Typed metadata carried on a `CallToolResult` under
405/// [`RELATED_TASK_META_KEY`], linking a synchronous tool result to the async
406/// task that produced (or is producing) it (SEP-1686).
407///
408/// This is the richer twin of [`RelatedTaskMetadata`]: it carries the optional
409/// polling hints a client needs to drive
410/// [`Client::wait_for_task`](crate::Client::wait_for_task) without hand-copying
411/// fields. Server code builds it via
412/// [`CallToolResult::with_related_task`](crate::types::CallToolResult::with_related_task);
413/// client code reads it via
414/// [`CallToolResult::related_task`](crate::types::CallToolResult::related_task).
415///
416/// The two `Option` polling fields default to `None`, so the minimal native
417/// emit shape `{ "taskId": "t1" }` deserializes cleanly (extra fields absent).
418///
419/// # Backward Compatibility
420///
421/// This struct is `#[non_exhaustive]`. Construct it via [`TaskMetadata::new`]
422/// and the builder methods to stay forward-compatible:
423///
424/// ```rust
425/// use pmcp::types::tasks::TaskMetadata;
426///
427/// let meta = TaskMetadata::new("t-123")
428///     .with_poll_interval(5000)
429///     .with_max_poll_duration_secs(300);
430/// let json = serde_json::to_value(&meta).unwrap();
431/// assert_eq!(json["taskId"], "t-123");
432/// assert_eq!(json["pollInterval"], 5000);
433/// ```
434#[derive(Debug, Clone, Serialize, Deserialize)]
435#[non_exhaustive]
436#[serde(rename_all = "camelCase")]
437pub struct TaskMetadata {
438    /// The referenced task ID.
439    pub task_id: String,
440    /// Suggested polling interval, in **milliseconds**.
441    ///
442    /// This is the same unit as [`Task::poll_interval`]. A client poller uses
443    /// it as the delay between `tasks/get` calls.
444    #[serde(skip_serializing_if = "Option::is_none")]
445    pub poll_interval: Option<u64>,
446    /// Maximum total time to poll before giving up, in **seconds**.
447    ///
448    /// Note the unit differs from [`TaskMetadata::poll_interval`] (which is
449    /// milliseconds): this is a coarse overall budget expressed in seconds.
450    #[serde(skip_serializing_if = "Option::is_none")]
451    pub max_poll_duration_secs: Option<u64>,
452}
453
454impl TaskMetadata {
455    /// Create related-task metadata referencing `task_id`.
456    ///
457    /// Both polling hints default to `None`; set them via
458    /// [`TaskMetadata::with_poll_interval`] and
459    /// [`TaskMetadata::with_max_poll_duration_secs`].
460    pub fn new(task_id: impl Into<String>) -> Self {
461        Self {
462            task_id: task_id.into(),
463            poll_interval: None,
464            max_poll_duration_secs: None,
465        }
466    }
467
468    /// Set the suggested polling interval, in **milliseconds**.
469    pub fn with_poll_interval(mut self, interval_ms: u64) -> Self {
470        self.poll_interval = Some(interval_ms);
471        self
472    }
473
474    /// Set the maximum total polling duration, in **seconds**.
475    pub fn with_max_poll_duration_secs(mut self, secs: u64) -> Self {
476        self.max_poll_duration_secs = Some(secs);
477        self
478    }
479}
480
481/// Result of creating a task.
482#[derive(Debug, Clone, Serialize, Deserialize)]
483#[non_exhaustive]
484#[serde(rename_all = "camelCase")]
485pub struct CreateTaskResult {
486    /// The created task
487    pub task: Task,
488}
489
490impl CreateTaskResult {
491    /// Create a task creation result.
492    pub fn new(task: Task) -> Self {
493        Self { task }
494    }
495}
496
497/// Task status notification.
498#[derive(Debug, Clone, Serialize, Deserialize)]
499#[serde(rename_all = "camelCase")]
500pub struct TaskStatusNotification {
501    /// Task with updated status
502    pub task: Task,
503}
504
505/// Get task request.
506#[derive(Debug, Clone, Serialize, Deserialize)]
507#[serde(rename_all = "camelCase")]
508pub struct GetTaskRequest {
509    /// Task ID to retrieve
510    pub task_id: String,
511}
512
513/// Get task result.
514#[derive(Debug, Clone, Serialize, Deserialize)]
515#[non_exhaustive]
516#[serde(rename_all = "camelCase")]
517pub struct GetTaskResult {
518    /// The requested task
519    pub task: Task,
520}
521
522impl GetTaskResult {
523    /// Create a get task result.
524    pub fn new(task: Task) -> Self {
525        Self { task }
526    }
527}
528
529/// Get task payload request.
530#[derive(Debug, Clone, Serialize, Deserialize)]
531#[serde(rename_all = "camelCase")]
532pub struct GetTaskPayloadRequest {
533    /// Task ID whose payload to retrieve
534    pub task_id: String,
535}
536
537/// List tasks request (paginated).
538#[derive(Debug, Clone, Default, Serialize, Deserialize)]
539#[serde(rename_all = "camelCase")]
540pub struct ListTasksRequest {
541    /// Pagination cursor
542    #[serde(skip_serializing_if = "Option::is_none")]
543    pub cursor: Option<String>,
544}
545
546/// List tasks result.
547#[derive(Debug, Clone, Default, Serialize, Deserialize)]
548#[non_exhaustive]
549#[serde(rename_all = "camelCase")]
550pub struct ListTasksResult {
551    /// List of tasks
552    pub tasks: Vec<Task>,
553    /// Pagination cursor for next page
554    #[serde(skip_serializing_if = "Option::is_none")]
555    pub next_cursor: Option<String>,
556}
557
558impl ListTasksResult {
559    /// Create a list tasks result.
560    pub fn new(tasks: Vec<Task>) -> Self {
561        Self {
562            tasks,
563            next_cursor: None,
564        }
565    }
566
567    /// Set the pagination cursor for the next page.
568    pub fn with_next_cursor(mut self, cursor: impl Into<String>) -> Self {
569        self.next_cursor = Some(cursor.into());
570        self
571    }
572}
573
574/// Cancel task request.
575///
576/// When `result` is `Some`, the task transitions to `Completed` status
577/// (workflow completion). When `result` is `None`, the task transitions to
578/// `Cancelled` status (standard cancel).
579#[derive(Debug, Clone, Serialize, Deserialize)]
580#[serde(rename_all = "camelCase")]
581pub struct CancelTaskRequest {
582    /// Task ID to cancel
583    pub task_id: String,
584    /// Optional result value for workflow completion.
585    ///
586    /// When present, completes the task (transitions to `Completed` status)
587    /// instead of cancelling it.
588    #[serde(skip_serializing_if = "Option::is_none")]
589    pub result: Option<serde_json::Value>,
590}
591
592/// Cancel task result.
593#[derive(Debug, Clone, Serialize, Deserialize)]
594#[non_exhaustive]
595#[serde(rename_all = "camelCase")]
596pub struct CancelTaskResult {
597    /// The cancelled task
598    pub task: Task,
599}
600
601impl CancelTaskResult {
602    /// Create a cancel task result.
603    pub fn new(task: Task) -> Self {
604        Self { task }
605    }
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611    use serde_json::json;
612
613    #[test]
614    fn task_status_serialization() {
615        assert_eq!(
616            serde_json::to_value(TaskStatus::Working).unwrap(),
617            "working"
618        );
619        assert_eq!(
620            serde_json::to_value(TaskStatus::InputRequired).unwrap(),
621            "input_required"
622        );
623        assert_eq!(
624            serde_json::to_value(TaskStatus::Completed).unwrap(),
625            "completed"
626        );
627        assert_eq!(serde_json::to_value(TaskStatus::Failed).unwrap(), "failed");
628        assert_eq!(
629            serde_json::to_value(TaskStatus::Cancelled).unwrap(),
630            "cancelled"
631        );
632    }
633
634    #[test]
635    fn task_roundtrip() {
636        let task = Task::new("t-123", TaskStatus::Working)
637            .with_timestamps("2025-11-25T00:00:00Z", "2025-11-25T00:01:00Z")
638            .with_ttl(60000)
639            .with_poll_interval(5000)
640            .with_status_message("Processing...");
641        let json = serde_json::to_value(&task).unwrap();
642        assert_eq!(json["taskId"], "t-123");
643        assert_eq!(json["status"], "working");
644        assert_eq!(json["ttl"], 60000);
645        assert_eq!(json["createdAt"], "2025-11-25T00:00:00Z");
646        assert_eq!(json["pollInterval"], 5000);
647
648        let roundtrip: Task = serde_json::from_value(json).unwrap();
649        assert_eq!(roundtrip.task_id, "t-123");
650        assert_eq!(roundtrip.status, TaskStatus::Working);
651    }
652
653    #[test]
654    fn create_task_result_roundtrip() {
655        let result = CreateTaskResult::new(
656            Task::new("t-456", TaskStatus::Completed)
657                .with_timestamps("2025-11-25T00:00:00Z", "2025-11-25T00:05:00Z"),
658        );
659        let json = serde_json::to_value(&result).unwrap();
660        assert_eq!(json["task"]["taskId"], "t-456");
661        assert_eq!(json["task"]["status"], "completed");
662
663        let roundtrip: CreateTaskResult = serde_json::from_value(json).unwrap();
664        assert_eq!(roundtrip.task.status, TaskStatus::Completed);
665    }
666
667    #[test]
668    fn task_ts_format_interop() {
669        // Test deserialization from TypeScript-format JSON
670        let ts_json = json!({
671            "taskId": "task-abc",
672            "status": "input_required",
673            "createdAt": "2025-11-25T12:00:00.000Z",
674            "lastUpdatedAt": "2025-11-25T12:01:00.000Z",
675            "pollInterval": 3000,
676            "statusMessage": "Waiting for user input"
677        });
678        let task: Task = serde_json::from_value(ts_json).unwrap();
679        assert_eq!(task.task_id, "task-abc");
680        assert_eq!(task.status, TaskStatus::InputRequired);
681        assert_eq!(task.poll_interval, Some(3000));
682    }
683
684    #[test]
685    fn task_metadata_serde_round_trip() {
686        let meta = TaskMetadata {
687            task_id: "t1".to_string(),
688            poll_interval: Some(5000),
689            max_poll_duration_secs: None,
690        };
691        let json = serde_json::to_value(&meta).unwrap();
692        assert_eq!(json["taskId"], "t1");
693        assert_eq!(json["pollInterval"], 5000);
694        // maxPollDurationSecs omitted (skip_serializing_if = None)
695        assert!(
696            json.get("maxPollDurationSecs").is_none(),
697            "maxPollDurationSecs should be omitted when None"
698        );
699
700        let roundtrip: TaskMetadata = serde_json::from_value(json).unwrap();
701        assert_eq!(roundtrip.task_id, "t1");
702        assert_eq!(roundtrip.poll_interval, Some(5000));
703        assert_eq!(roundtrip.max_poll_duration_secs, None);
704    }
705
706    #[test]
707    fn task_metadata_minimal_shape_deserializes() {
708        // The minimal native emit shape carries only taskId.
709        let meta: TaskMetadata = serde_json::from_value(json!({ "taskId": "t1" })).unwrap();
710        assert_eq!(meta.task_id, "t1");
711        assert_eq!(meta.poll_interval, None);
712        assert_eq!(meta.max_poll_duration_secs, None);
713    }
714
715    #[test]
716    fn related_task_meta_key_value() {
717        assert_eq!(
718            RELATED_TASK_META_KEY,
719            "io.modelcontextprotocol/related-task"
720        );
721    }
722
723    #[test]
724    fn task_status_is_terminal() {
725        assert!(!TaskStatus::Working.is_terminal());
726        assert!(!TaskStatus::InputRequired.is_terminal());
727        assert!(TaskStatus::Completed.is_terminal());
728        assert!(TaskStatus::Failed.is_terminal());
729        assert!(TaskStatus::Cancelled.is_terminal());
730    }
731
732    #[test]
733    fn task_status_can_transition_to() {
734        // Working can transition to all except itself
735        assert!(TaskStatus::Working.can_transition_to(&TaskStatus::InputRequired));
736        assert!(TaskStatus::Working.can_transition_to(&TaskStatus::Completed));
737        assert!(TaskStatus::Working.can_transition_to(&TaskStatus::Failed));
738        assert!(TaskStatus::Working.can_transition_to(&TaskStatus::Cancelled));
739
740        // InputRequired can transition to all except itself
741        assert!(TaskStatus::InputRequired.can_transition_to(&TaskStatus::Working));
742        assert!(TaskStatus::InputRequired.can_transition_to(&TaskStatus::Completed));
743        assert!(TaskStatus::InputRequired.can_transition_to(&TaskStatus::Failed));
744        assert!(TaskStatus::InputRequired.can_transition_to(&TaskStatus::Cancelled));
745    }
746
747    #[test]
748    fn task_status_self_transition_rejected() {
749        assert!(!TaskStatus::Working.can_transition_to(&TaskStatus::Working));
750        assert!(!TaskStatus::InputRequired.can_transition_to(&TaskStatus::InputRequired));
751        assert!(!TaskStatus::Completed.can_transition_to(&TaskStatus::Completed));
752        assert!(!TaskStatus::Failed.can_transition_to(&TaskStatus::Failed));
753        assert!(!TaskStatus::Cancelled.can_transition_to(&TaskStatus::Cancelled));
754    }
755
756    #[test]
757    fn task_status_terminal_rejects_all() {
758        for terminal in [
759            TaskStatus::Completed,
760            TaskStatus::Failed,
761            TaskStatus::Cancelled,
762        ] {
763            for target in [
764                TaskStatus::Working,
765                TaskStatus::InputRequired,
766                TaskStatus::Completed,
767                TaskStatus::Failed,
768                TaskStatus::Cancelled,
769            ] {
770                assert!(
771                    !terminal.can_transition_to(&target),
772                    "{terminal:?} should not transition to {target:?}"
773                );
774            }
775        }
776    }
777
778    #[test]
779    fn task_ttl_null_serialization() {
780        let task = Task::new("test-null-ttl", TaskStatus::Working)
781            .with_timestamps("2025-11-25T00:00:00Z", "2025-11-25T00:01:00Z");
782        let json = serde_json::to_value(&task).unwrap();
783        // ttl MUST be present as null, not omitted (MCP spec: number | null)
784        assert!(json.get("ttl").is_some(), "ttl must be present");
785        assert!(json["ttl"].is_null(), "ttl must be null when None");
786        // pollInterval SHOULD be omitted when None
787        assert!(
788            json.get("pollInterval").is_none(),
789            "pollInterval should be omitted when None"
790        );
791    }
792
793    #[test]
794    fn task_ttl_present_serialization() {
795        let task = Task::new("test-present-ttl", TaskStatus::Working)
796            .with_timestamps("2025-11-25T00:00:00Z", "2025-11-25T00:01:00Z")
797            .with_ttl(60000);
798        let json = serde_json::to_value(&task).unwrap();
799        assert_eq!(json["ttl"], 60000);
800    }
801
802    #[test]
803    fn task_diagnostic_detail_absent_when_none() {
804        // D-17 PMCP extension: diagnostic_detail defaults to None and MUST be
805        // skip-serialized — byte-identical to pre-field JSON for callers that
806        // never set it.
807        let task = Task::new("t-diag-none", TaskStatus::Working)
808            .with_timestamps("2025-11-25T00:00:00Z", "2025-11-25T00:01:00Z");
809        assert_eq!(task.diagnostic_detail, None);
810        let json = serde_json::to_value(&task).unwrap();
811        assert!(
812            json.get("diagnosticDetail").is_none(),
813            "diagnosticDetail must be omitted from JSON when None"
814        );
815    }
816
817    #[test]
818    fn task_diagnostic_detail_round_trip() {
819        // D-17 PMCP extension serde round-trip: Some(...) serializes under the
820        // camelCase `diagnosticDetail` key and deserializes back unchanged.
821        let task = Task::new("t-diag-some", TaskStatus::Failed)
822            .with_timestamps("2025-11-25T00:00:00Z", "2025-11-25T00:01:00Z")
823            .with_status_message("The AI service was temporarily unavailable")
824            .with_diagnostic_detail(
825                "step=call_tool op=propose_schema url=https://api.example/v1/x error=timeout",
826            );
827        let json = serde_json::to_value(&task).unwrap();
828        assert_eq!(
829            json["diagnosticDetail"],
830            "step=call_tool op=propose_schema url=https://api.example/v1/x error=timeout"
831        );
832        // Both voices ride independently — statusMessage stays the friendly one.
833        assert_eq!(
834            json["statusMessage"],
835            "The AI service was temporarily unavailable"
836        );
837
838        let roundtrip: Task = serde_json::from_value(json).unwrap();
839        assert_eq!(
840            roundtrip.diagnostic_detail.as_deref(),
841            Some("step=call_tool op=propose_schema url=https://api.example/v1/x error=timeout")
842        );
843        assert_eq!(
844            roundtrip.status_message.as_deref(),
845            Some("The AI service was temporarily unavailable")
846        );
847    }
848
849    #[test]
850    fn task_diagnostic_detail_consumer_tolerance() {
851        // Review concern #3 / T-162-05-COMPAT: a Task JSON carrying the NEW
852        // diagnosticDetail key (plus a hypothetical extra unknown key a
853        // strict/older consumer wouldn't recognize) deserializes cleanly into
854        // `Task` — proving Task does NOT use deny_unknown_fields and existing
855        // consumers tolerate additive wire fields.
856        let wire_json = json!({
857            "taskId": "task-tolerant",
858            "status": "failed",
859            "ttl": null,
860            "createdAt": "2025-11-25T12:00:00.000Z",
861            "lastUpdatedAt": "2025-11-25T12:01:00.000Z",
862            "statusMessage": "The AI service was temporarily unavailable",
863            "diagnosticDetail": "step=call_tool op=propose_schema error=timeout",
864            "someFutureUnknownField": { "nested": "value" }
865        });
866        let task: Task = serde_json::from_value(wire_json)
867            .expect("Task must tolerate diagnosticDetail + an unrelated unknown field");
868        assert_eq!(task.task_id, "task-tolerant");
869        assert_eq!(task.status, TaskStatus::Failed);
870        assert_eq!(
871            task.diagnostic_detail.as_deref(),
872            Some("step=call_tool op=propose_schema error=timeout")
873        );
874        assert_eq!(
875            task.status_message.as_deref(),
876            Some("The AI service was temporarily unavailable")
877        );
878    }
879
880    #[test]
881    fn task_status_display() {
882        assert_eq!(TaskStatus::Working.to_string(), "working");
883        assert_eq!(TaskStatus::InputRequired.to_string(), "input_required");
884        assert_eq!(TaskStatus::Completed.to_string(), "completed");
885        assert_eq!(TaskStatus::Failed.to_string(), "failed");
886        assert_eq!(TaskStatus::Cancelled.to_string(), "cancelled");
887    }
888
889    /// All five `TaskStatus` values, for exhaustive table tests.
890    const ALL_STATUSES: [TaskStatus; 5] = [
891        TaskStatus::Working,
892        TaskStatus::InputRequired,
893        TaskStatus::Completed,
894        TaskStatus::Failed,
895        TaskStatus::Cancelled,
896    ];
897
898    /// The expected `poll_decision()` classification for a status, given the
899    /// task's `poll_interval` (only consulted for the `Working` case).
900    fn expected_decision(status: TaskStatus, poll_interval: Option<u64>) -> TaskPollDecision {
901        match status {
902            TaskStatus::Working => TaskPollDecision::InProgress {
903                poll_hint: poll_interval,
904            },
905            TaskStatus::InputRequired => TaskPollDecision::InputRequired,
906            TaskStatus::Completed | TaskStatus::Failed | TaskStatus::Cancelled => {
907                TaskPollDecision::Terminal { status }
908            },
909        }
910    }
911
912    #[test]
913    fn poll_decision_maps_every_status() {
914        // Working -> InProgress carrying the poll_interval verbatim.
915        let working = Task::new("t-w", TaskStatus::Working).with_poll_interval(2500);
916        assert_eq!(
917            working.poll_decision(),
918            TaskPollDecision::InProgress {
919                poll_hint: Some(2500)
920            }
921        );
922
923        // Working with no poll_interval -> InProgress { poll_hint: None }.
924        let working_none = Task::new("t-wn", TaskStatus::Working);
925        assert_eq!(
926            working_none.poll_decision(),
927            TaskPollDecision::InProgress { poll_hint: None }
928        );
929
930        // InputRequired -> unit variant.
931        let waiting = Task::new("t-i", TaskStatus::InputRequired);
932        assert_eq!(waiting.poll_decision(), TaskPollDecision::InputRequired);
933
934        // Each terminal status -> Terminal carrying that status.
935        for status in [
936            TaskStatus::Completed,
937            TaskStatus::Failed,
938            TaskStatus::Cancelled,
939        ] {
940            let task = Task::new("t-term", status);
941            assert_eq!(
942                task.poll_decision(),
943                TaskPollDecision::Terminal { status },
944                "{status:?} must classify as Terminal"
945            );
946        }
947    }
948
949    #[test]
950    fn poll_decision_covers_all_statuses_exhaustively() {
951        // Table drive across EVERY TaskStatus value (guards D-15 exhaustiveness).
952        for status in ALL_STATUSES {
953            let task = Task::new("t-x", status).with_poll_interval(1234);
954            assert_eq!(
955                task.poll_decision(),
956                expected_decision(status, Some(1234)),
957                "poll_decision() drifted for {status:?}"
958            );
959        }
960    }
961
962    #[test]
963    fn resolve_poll_interval_precedence() {
964        // Caller override wins over the server hint.
965        assert_eq!(resolve_poll_interval(Some(200), Some(999)), 200);
966        // Server hint used when there is no caller override.
967        assert_eq!(resolve_poll_interval(None, Some(300)), 300);
968        // Falls back to DEFAULT_POLL_MS when neither is specified.
969        assert_eq!(resolve_poll_interval(None, None), DEFAULT_POLL_MS);
970        assert_eq!(resolve_poll_interval(None, None), 1000);
971    }
972
973    #[test]
974    fn resolve_poll_interval_floors_zero() {
975        // A zero override cannot hot-spin — floored to MIN_POLL_MS.
976        assert_eq!(resolve_poll_interval(Some(0), None), MIN_POLL_MS);
977        assert_eq!(resolve_poll_interval(Some(0), None), 50);
978        // The floor also applies to a low server hint.
979        assert_eq!(resolve_poll_interval(None, Some(10)), 50);
980        // And to a zero hint.
981        assert_eq!(resolve_poll_interval(None, Some(0)), 50);
982    }
983
984    proptest::proptest! {
985        /// For every TaskStatus and any poll_interval, poll_decision() returns
986        /// exactly the mapped variant — the classifier never drifts.
987        #[test]
988        fn poll_decision_matches_expected_map(
989            status_idx in 0usize..ALL_STATUSES.len(),
990            poll_interval in proptest::option::of(proptest::prelude::any::<u64>()),
991        ) {
992            let status = ALL_STATUSES[status_idx];
993            let mut task = Task::new("t-prop", status);
994            task.poll_interval = poll_interval;
995            proptest::prop_assert_eq!(
996                task.poll_decision(),
997                expected_decision(status, poll_interval)
998            );
999        }
1000
1001        /// The 50 ms floor holds for ALL caller/hint inputs, not just the
1002        /// tabled cases (T-105-01 invariant).
1003        #[test]
1004        fn resolve_poll_interval_never_below_floor(
1005            caller in proptest::option::of(proptest::prelude::any::<u64>()),
1006            hint in proptest::option::of(proptest::prelude::any::<u64>()),
1007        ) {
1008            proptest::prop_assert!(resolve_poll_interval(caller, hint) >= MIN_POLL_MS);
1009        }
1010    }
1011}