#![cfg(not(target_arch = "wasm32"))]
#![allow(clippy::redundant_pub_crate)]
use crate::error::{Error, Result};
use crate::server::auth::AuthContext;
use crate::server::core::DispatchEnvelopeClaim;
use crate::server::task_store::{TaskInputSnapshot, TaskStore, TaskStoreError};
use crate::server::tasks::TaskRouter;
use crate::types::capabilities::{
ServerCapabilities, ServerTasksCapability, TasksExtensionCapability, TASKS_EXTENSION_KEY,
};
use crate::types::jsonrpc::ResponsePayload;
use crate::types::mrtr::TASKS_UPDATE_METHOD;
use crate::types::mrtr::{
check_input_responses_map_bounds, InputResponse, InputResponseTypingError, InputResponses,
INPUT_RESPONSES_KEY,
};
use crate::types::tasks::{
DetailedTaskV2, Task, TaskDetailV2, TaskStatus, TaskV2, RELATED_TASK_META_KEY,
};
use crate::types::tools::TaskSupport;
use crate::types::{
CallToolResult, ClientRequest, Content, JSONRPCError, JSONRPCResponse, RequestId, ToolInfo,
};
use serde_json::Value;
use std::collections::HashMap;
use std::sync::Arc;
const TASKS_LIST_METHOD: &str = "tasks/list";
const TASKS_RESULT_METHOD: &str = "tasks/result";
pub(crate) const V2_TASKS_METHOD_RETIRED: &str =
"is not a method of the tasks extension on protocol version 2026-07-28: the extension \
declares only tasks/get, tasks/update and tasks/cancel";
const V1_UNAUTHENTICATED_OWNER: &str = "local";
const TASKS_NOT_ENABLED: &str = "Tasks not enabled";
const TASKS_RESULT_NOT_SUPPORTED: &str = "tasks/result not supported";
const NOT_A_TASKS_METHOD: &str = "Method not supported";
const V2_TASK_NOT_FOUND_MESSAGE: &str = "task not found";
const MISSING_TASKS_DECLARATION_MESSAGE: &str =
"the tasks extension was not declared on this request: send \
_meta[\"io.modelcontextprotocol/clientCapabilities\"].extensions\
[\"io.modelcontextprotocol/tasks\"]";
const V1_TASKS_UPDATE_ABSENT: &str =
"is not a method of protocol version 2025-11-25: it is defined only by the tasks extension \
on protocol version 2026-07-28";
const TASKS_UPDATE_MALFORMED_PARAMS: &str = "tasks/update requires params.taskId to be a string";
const TASKS_UPDATE_MISSING_INPUT_RESPONSES: &str =
"tasks/update requires params.inputResponses to be an object";
fn retired_on_v2(id: RequestId, method: &str) -> JSONRPCResponse {
error_response(
id,
crate::types::protocol::error_codes::METHOD_NOT_FOUND,
format!("{method} {V2_TASKS_METHOD_RETIRED}"),
)
}
pub(crate) const fn is_v1_task_era(era: Option<crate::types::protocol::Era>) -> bool {
!matches!(era, Some(crate::types::protocol::Era::V2))
}
pub(crate) const fn tasks_list_serves_on_era(era: Option<crate::types::protocol::Era>) -> bool {
is_v1_task_era(era)
}
pub(crate) const fn tasks_result_serves_on_era(era: Option<crate::types::protocol::Era>) -> bool {
is_v1_task_era(era)
}
pub(crate) fn default_tasks_capability() -> ServerTasksCapability {
ServerTasksCapability {
list: Some(serde_json::json!({})),
cancel: Some(serde_json::json!({})),
requests: Some(crate::types::capabilities::ServerTasksRequestCapability {
tools: Some(crate::types::capabilities::ServerTasksToolsCapability {
call: Some(serde_json::json!({})),
}),
}),
}
}
pub(crate) fn tasks_extension_value() -> Value {
serde_json::to_value(TasksExtensionCapability::default())
.unwrap_or_else(|_| Value::Object(serde_json::Map::new()))
}
pub(crate) fn apply_tasks_capability_rule(
capabilities: &mut ServerCapabilities,
tool_infos: &HashMap<String, ToolInfo>,
has_backend: bool,
) -> Result<()> {
let has_required_task_tool = tool_infos.values().any(|info| {
info.execution
.as_ref()
.and_then(|e| e.task_support)
.is_some_and(|ts| matches!(ts, TaskSupport::Required))
});
if has_required_task_tool && !has_backend {
return Err(Error::validation(
"a tool declares TaskSupport::Required but no TaskStore or TaskRouter \
is configured to back the tasks/* endpoints",
));
}
if capabilities.tasks.is_none() && has_backend {
capabilities.tasks = Some(default_tasks_capability());
}
if has_backend {
capabilities
.extensions
.get_or_insert_with(HashMap::new)
.entry(TASKS_EXTENSION_KEY.to_string())
.or_insert_with(tasks_extension_value);
}
Ok(())
}
pub(crate) fn success_response(id: RequestId, result: Value) -> JSONRPCResponse {
JSONRPCResponse {
jsonrpc: "2.0".to_string(),
id,
payload: ResponsePayload::Result(result),
}
}
pub(crate) fn error_response(id: RequestId, code: i32, message: String) -> JSONRPCResponse {
JSONRPCResponse {
jsonrpc: "2.0".to_string(),
id,
payload: ResponsePayload::Error(JSONRPCError {
code,
message,
data: None,
}),
}
}
pub(crate) enum DispatchOutput {
Verbatim(CallToolResult),
Middleware(Result<Value>),
}
fn missing_tasks_declaration_refusal(id: RequestId) -> JSONRPCResponse {
let mut extensions = std::collections::HashMap::new();
extensions.insert(
crate::types::capabilities::TASKS_EXTENSION_KEY.to_string(),
serde_json::to_value(crate::types::capabilities::TasksExtensionCapability::default())
.unwrap_or_else(|_| Value::Object(serde_json::Map::new())),
);
let required = crate::types::ClientCapabilities {
extensions: Some(extensions),
..Default::default()
};
JSONRPCResponse {
jsonrpc: "2.0".to_string(),
id,
payload: ResponsePayload::Error(JSONRPCError {
code: crate::types::protocol::error_codes::MISSING_REQUIRED_CLIENT_CAPABILITY,
message: MISSING_TASKS_DECLARATION_MESSAGE.to_string(),
data: Some(serde_json::json!({
"requiredCapabilities": serde_json::to_value(&required)
.unwrap_or_else(|_| Value::Object(serde_json::Map::new())),
})),
}),
}
}
pub(crate) fn authentication_required(id: RequestId, method: &str) -> JSONRPCResponse {
error_response(
id,
crate::types::protocol::error_codes::AUTHENTICATION_REQUIRED,
format!("{method} requires an authenticated caller on this server"),
)
}
fn store_error_response(
id: RequestId,
error: &TaskStoreError,
era: Option<crate::types::protocol::Era>,
) -> JSONRPCResponse {
if is_v1_task_era(era) {
return error_response(
id,
crate::types::protocol::error_codes::INTERNAL_ERROR,
error.to_string(),
);
}
match error {
TaskStoreError::NotFound { .. } | TaskStoreError::Expired { .. } => error_response(
id,
crate::types::protocol::error_codes::INVALID_PARAMS,
V2_TASK_NOT_FOUND_MESSAGE.to_string(),
),
TaskStoreError::InvalidTransition { .. } | TaskStoreError::Internal { .. } => {
error_response(
id,
crate::types::protocol::error_codes::INTERNAL_ERROR,
error.to_string(),
)
},
}
}
fn as_object(value: Value) -> Option<serde_json::Map<String, Value>> {
match value {
Value::Object(map) => Some(map),
_ => None,
}
}
fn related_task_meta(task_id: &str) -> Value {
serde_json::json!({ RELATED_TASK_META_KEY: { "taskId": task_id } })
}
fn v2_create_result_value(task: &Task, store_id: &str) -> Value {
let mut object = as_object(serde_json::to_value(TaskV2::from_v1(task)).unwrap_or_default())
.unwrap_or_default();
object.insert("_meta".to_string(), related_task_meta(store_id));
Value::Object(object)
}
fn v1_create_result_value(task: &Task, store_id: &str) -> Value {
let create_result = crate::types::tasks::CreateTaskResult::new(task.clone());
let mut envelope = serde_json::to_value(create_result).unwrap_or_default();
if let Some(object) = envelope.as_object_mut() {
object.insert("_meta".to_string(), related_task_meta(store_id));
}
envelope
}
fn v2_project_router_task(value: Value) -> Value {
let Some(object) = value.as_object() else {
return value;
};
let body = object.get("task").unwrap_or(&value);
let Ok(task) = serde_json::from_value::<Task>(body.clone()) else {
tracing::warn!(
target: "mcp.tasks",
"a TaskRouter returned a tasks/get value that is not a Task; passing it through \
unprojected on protocol version 2026-07-28"
);
return value;
};
let detail_source = |key: &str| -> Option<Value> {
object
.get(key)
.or_else(|| body.as_object().and_then(|inner| inner.get(key)))
.cloned()
};
let detail = match task.status {
TaskStatus::Working => Some(TaskDetailV2::Working),
TaskStatus::Cancelled => Some(TaskDetailV2::Cancelled),
TaskStatus::InputRequired => detail_source(crate::types::tasks::DETAIL_KEY_INPUT_REQUESTS)
.and_then(|v| serde_json::from_value(v).ok())
.map(|input_requests| TaskDetailV2::InputRequired { input_requests }),
TaskStatus::Completed => detail_source(crate::types::tasks::DETAIL_KEY_RESULT)
.and_then(as_object)
.map(|result| TaskDetailV2::Completed { result }),
TaskStatus::Failed => detail_source(crate::types::tasks::DETAIL_KEY_ERROR)
.and_then(as_object)
.map(|error| TaskDetailV2::Failed { error }),
};
v2_detailed_task_value(&task, detail)
}
fn v2_detailed_task_value(task: &Task, detail: Option<TaskDetailV2>) -> Value {
if let Some(detail) = detail {
return Value::Object(DetailedTaskV2::new(TaskV2::from_v1(task), detail).to_wire_object());
}
tracing::warn!(
target: "mcp.tasks",
status = %task.status,
"no backend could supply this task's status detail; emitting the bare flat Task \
rather than an empty required field"
);
serde_json::to_value(TaskV2::from_v1(task)).unwrap_or_default()
}
pub(crate) fn resolve_tool_output(output: Result<crate::server::ToolOutput>) -> DispatchOutput {
match output {
Ok(crate::server::ToolOutput::Result(call_result)) => DispatchOutput::Verbatim(call_result),
Ok(crate::server::ToolOutput::Payload(value)) => DispatchOutput::Middleware(Ok(value)),
Err(e) => DispatchOutput::Middleware(Err(e)),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DoubleWrapMarker {
RelatedTaskMeta,
ContentArray,
}
pub fn looks_like_call_tool_result(v: &Value) -> Option<DoubleWrapMarker> {
const RESULT_ENVELOPE_KEYS: [&str; 4] = ["content", "isError", "structuredContent", "_meta"];
let obj = v.as_object()?;
if obj
.get("_meta")
.and_then(Value::as_object)
.is_some_and(|meta| meta.contains_key(RELATED_TASK_META_KEY))
{
return Some(DoubleWrapMarker::RelatedTaskMeta);
}
if obj
.keys()
.all(|k| RESULT_ENVELOPE_KEYS.contains(&k.as_str()))
&& obj
.get("content")
.and_then(Value::as_array)
.is_some_and(|arr| {
!arr.is_empty()
&& arr
.iter()
.all(|e| serde_json::from_value::<Content>(e.clone()).is_ok())
})
{
return Some(DoubleWrapMarker::ContentArray);
}
None
}
pub fn double_wrap_tripwire(
tool_name: &str,
value: &Value,
suppressed: bool,
) -> Option<DoubleWrapMarker> {
if suppressed {
return None;
}
let marker = looks_like_call_tool_result(value)?;
tracing::warn!(
tool = %tool_name,
?marker,
"value being text-wrapped structurally resembles a built CallToolResult \
— did you mean ToolOutput::Result? (TOUT-02)"
);
debug_assert!(
false,
"double-wrap tripwire (TOUT-02): tool `{tool_name}` produced a value that \
structurally resembles a built CallToolResult ({marker:?}); return \
ToolOutput::Result to send it verbatim, or call \
suppress_double_wrap_check(\"{tool_name}\") if this payload is legitimate"
);
Some(marker)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum OwnerBinding {
Owner(String),
Refused,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CreateTrigger {
V1TaskField {
task_field_present: bool,
},
V2ClientDeclaration {
client_declared_tasks: bool,
},
}
impl CreateTrigger {
pub(crate) fn resolve(
era: Option<crate::types::protocol::Era>,
task_field_present: bool,
protocol_context: Option<&crate::types::protocol::ProtocolContext>,
) -> Self {
if is_v1_task_era(era) {
return Self::V1TaskField { task_field_present };
}
Self::V2ClientDeclaration {
client_declared_tasks: TaskDispatch::declares_tasks_extension(protocol_context, era),
}
}
const fn fired(self) -> bool {
match self {
Self::V1TaskField { task_field_present } => task_field_present,
Self::V2ClientDeclaration {
client_declared_tasks,
} => client_declared_tasks,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CreateGate {
Create,
NotTaskShaped,
Closed,
}
struct TasksUpdateParams<'a> {
task_id: String,
input_responses: &'a serde_json::Map<String, Value>,
}
fn update_ack(id: RequestId) -> JSONRPCResponse {
success_response(id, Value::Object(serde_json::Map::new()))
}
pub(crate) struct TaskDispatch<'a> {
pub(crate) task_store: &'a Option<Arc<dyn TaskStore>>,
pub(crate) task_router: &'a Option<Arc<dyn TaskRouter>>,
pub(crate) has_auth_provider: bool,
}
impl TaskDispatch<'_> {
const fn has_task_backend(&self) -> bool {
self.task_store.is_some() || self.task_router.is_some()
}
pub(crate) fn resolve_owner(
&self,
auth_context: Option<&AuthContext>,
era: Option<crate::types::protocol::Era>,
) -> OwnerBinding {
if is_v1_task_era(era) {
return OwnerBinding::Owner(self.resolve_v1_owner(auth_context));
}
let principal = crate::server::core::MrtrPrincipal {
authenticated_subject: auth_context.map(|ctx| ctx.subject.as_str()),
has_auth_provider: self.has_auth_provider,
};
crate::server::core::resolve_mrtr_principal(principal)
.map_or(OwnerBinding::Refused, |owner| {
OwnerBinding::Owner(owner.to_string())
})
}
fn resolve_v1_owner(&self, auth_context: Option<&AuthContext>) -> String {
if auth_context.is_none() {
tracing::warn!(
target: "mcp.tasks",
owner = V1_UNAUTHENTICATED_OWNER,
"an unauthenticated v1 task request was bound to the shared \"local\" owner \
bucket, which every other unauthenticated caller on this server also shares; \
protocol version 2026-07-28 binds the owner to the authenticated subject \
instead and refuses the request outright when an auth provider is configured"
);
}
if let Some(router) = self.task_router {
return match auth_context {
Some(ctx) => {
router.resolve_owner(Some(&ctx.subject), ctx.client_id.as_deref(), None)
},
None => router.resolve_owner(None, None, None),
};
}
match auth_context {
Some(ctx) => ctx.subject.clone(),
None => V1_UNAUTHENTICATED_OWNER.to_string(),
}
}
pub(crate) fn extract_terminal_result(value: &Value) -> Option<CallToolResult> {
if let Some(result_value) = value.get("result") {
return serde_json::from_value::<CallToolResult>(result_value.clone()).ok();
}
if value.get("content").is_some() {
return serde_json::from_value::<CallToolResult>(value.clone()).ok();
}
None
}
pub(crate) fn extract_input_requests(
value: &Value,
) -> Option<crate::types::mrtr::InputRequests> {
let status = value.get("status")?;
if serde_json::from_value::<TaskStatus>(status.clone()).ok()? != TaskStatus::InputRequired {
return None;
}
let requests = value.get("inputRequests")?;
if !requests.is_object() {
return None;
}
serde_json::from_value::<crate::types::mrtr::InputRequests>(requests.clone()).ok()
}
pub(crate) async fn build_task_created_response(
&self,
id: RequestId,
value: Value,
auth_context: Option<&AuthContext>,
era: Option<crate::types::protocol::Era>,
) -> (JSONRPCResponse, DispatchEnvelopeClaim) {
let v1 = is_v1_task_era(era);
let Some(store) = self.task_store.as_ref() else {
let tool_task_id = value
.get("taskId")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
if !v1 {
if let Ok(task) = serde_json::from_value::<Task>(value.clone()) {
return (
success_response(id, v2_create_result_value(&task, &tool_task_id)),
DispatchEnvelopeClaim::TASK_CREATED,
);
}
}
let result_value = serde_json::json!({
"task": value,
"_meta": { RELATED_TASK_META_KEY: { "taskId": tool_task_id } }
});
return (
success_response(id, result_value),
DispatchEnvelopeClaim::NONE,
);
};
let OwnerBinding::Owner(owner_id) = self.resolve_owner(auth_context, era) else {
return (
authentication_required(id, crate::types::mrtr::CALL_TOOL_METHOD),
DispatchEnvelopeClaim::NONE,
);
};
let ttl = value.get("ttl").and_then(serde_json::Value::as_u64);
let created = match store.create(&owner_id, ttl).await {
Ok(task) => task,
Err(e) => {
return (
store_error_response(id, &e, era),
DispatchEnvelopeClaim::NONE,
)
},
};
let store_id = created.task_id.clone();
let final_task = if let Some(call_result) = Self::extract_terminal_result(&value) {
if let Err(e) = store.set_result(&store_id, &owner_id, call_result).await {
return (
store_error_response(id, &e, era),
DispatchEnvelopeClaim::NONE,
);
}
match store
.update_status(&store_id, &owner_id, TaskStatus::Completed, None)
.await
{
Ok(task) => task,
Err(e) => {
return (
store_error_response(id, &e, era),
DispatchEnvelopeClaim::NONE,
)
},
}
} else if let Some(requests) = Self::extract_input_requests(&value) {
match store
.record_input_requests(&store_id, &owner_id, requests)
.await
{
Ok(task) => task,
Err(e) => {
return (
store_error_response(id, &e, era),
DispatchEnvelopeClaim::NONE,
)
},
}
} else {
created
};
if v1 {
return (
success_response(id, v1_create_result_value(&final_task, &store_id)),
DispatchEnvelopeClaim::NONE,
);
}
(
success_response(id, v2_create_result_value(&final_task, &store_id)),
DispatchEnvelopeClaim::TASK_CREATED,
)
}
pub(crate) fn create_gate(
&self,
trigger: CreateTrigger,
task_support: Option<TaskSupport>,
value: &Value,
) -> CreateGate {
let gate_open = trigger.fired()
&& self.task_store.is_some()
&& task_support
.is_some_and(|ts| matches!(ts, TaskSupport::Required | TaskSupport::Optional));
if !gate_open {
return CreateGate::Closed;
}
let is_task_shaped =
value.get("taskId").and_then(Value::as_str).is_some() && value.get("status").is_some();
if is_task_shaped {
CreateGate::Create
} else {
CreateGate::NotTaskShaped
}
}
pub(crate) async fn maybe_build_task_created(
&self,
id: RequestId,
value: &Value,
task_support: Option<TaskSupport>,
trigger: CreateTrigger,
auth_context: Option<&AuthContext>,
era: Option<crate::types::protocol::Era>,
) -> Option<(JSONRPCResponse, DispatchEnvelopeClaim)> {
match self.create_gate(trigger, task_support, value) {
CreateGate::Create => Some(
self.build_task_created_response(id, value.clone(), auth_context, era)
.await,
),
CreateGate::NotTaskShaped | CreateGate::Closed => None,
}
}
pub(crate) async fn handle_tasks_result(
&self,
id: RequestId,
params: &crate::types::tasks::GetTaskPayloadRequest,
owner_id: &str,
era: Option<crate::types::protocol::Era>,
) -> JSONRPCResponse {
if let Some(store) = self.task_store {
if store.supports_results() {
match store.get_result(¶ms.task_id, owner_id).await {
Ok(call_result) => {
return success_response(
id,
serde_json::to_value(call_result).unwrap_or_default(),
);
},
Err(crate::server::task_store::TaskStoreError::NotFound { .. }) => {},
Err(e) => {
return error_response(
id,
crate::types::protocol::error_codes::INTERNAL_ERROR,
e.to_string(),
)
},
}
}
}
if let Some(task_router) = self.task_router {
return match task_router
.handle_tasks_result(serde_json::to_value(params).unwrap_or_default(), owner_id)
.await
{
Ok(result) => success_response(id, result),
Err(e) => error_response(
id,
crate::types::protocol::error_codes::INTERNAL_ERROR,
e.to_string(),
),
};
}
match (self.task_store.is_some(), tasks_result_serves_on_era(era)) {
(true, true) => error_response(
id,
crate::types::protocol::error_codes::V1_TASK_PENDING,
"task result not available: task not completed".to_string(),
),
(true, false) => retired_on_v2(id, TASKS_RESULT_METHOD),
(false, _) => error_response(
id,
crate::types::protocol::error_codes::METHOD_NOT_FOUND,
TASKS_RESULT_NOT_SUPPORTED.to_string(),
),
}
}
async fn v2_task_detail(&self, task: &Task, owner_id: &str) -> Option<TaskDetailV2> {
let store = self.task_store.as_ref()?;
match task.status {
TaskStatus::Working => Some(TaskDetailV2::Working),
TaskStatus::Cancelled => Some(TaskDetailV2::Cancelled),
TaskStatus::InputRequired => store
.task_input_snapshot(&task.task_id, owner_id)
.await
.ok()
.map(|snapshot| TaskDetailV2::InputRequired {
input_requests: snapshot.input_requests,
}),
TaskStatus::Completed => {
if !store.supports_results() {
return None;
}
store
.get_result(&task.task_id, owner_id)
.await
.ok()
.and_then(|result| as_object(serde_json::to_value(result).ok()?))
.map(|result| TaskDetailV2::Completed { result })
},
TaskStatus::Failed => store
.get_error(&task.task_id, owner_id)
.await
.ok()
.and_then(as_object)
.map(|error| TaskDetailV2::Failed { error }),
}
}
async fn v2_get_response(
&self,
id: RequestId,
task: &Task,
owner_id: &str,
) -> (JSONRPCResponse, DispatchEnvelopeClaim) {
let detail = self.v2_task_detail(task, owner_id).await;
let claims_input_requests = matches!(detail, Some(TaskDetailV2::InputRequired { .. }));
let response = success_response(id, v2_detailed_task_value(task, detail));
let claim = if claims_input_requests {
DispatchEnvelopeClaim::TASKS_INPUT_REQUIRED
} else {
DispatchEnvelopeClaim::NONE
};
(response, claim)
}
async fn route_tasks_get(
&self,
id: RequestId,
params: &crate::types::tasks::GetTaskRequest,
owner_id: &str,
era: Option<crate::types::protocol::Era>,
) -> (JSONRPCResponse, DispatchEnvelopeClaim) {
let v1 = is_v1_task_era(era);
if let Some(store) = self.task_store {
return match store.get(¶ms.task_id, owner_id).await {
Ok(task) if v1 => {
let result = crate::types::tasks::GetTaskResult::new(task);
(
success_response(id, serde_json::to_value(result).unwrap_or_default()),
DispatchEnvelopeClaim::NONE,
)
},
Ok(task) => self.v2_get_response(id, &task, owner_id).await,
Err(e) => (
store_error_response(id, &e, era),
DispatchEnvelopeClaim::NONE,
),
};
}
if let Some(task_router) = self.task_router {
return match task_router
.handle_tasks_get(serde_json::to_value(params).unwrap_or_default(), owner_id)
.await
{
Ok(result) if v1 => (success_response(id, result), DispatchEnvelopeClaim::NONE),
Ok(result) => {
let projected = v2_project_router_task(result);
let claims = projected
.get(crate::types::tasks::DETAIL_KEY_INPUT_REQUESTS)
.is_some();
(
success_response(id, projected),
if claims {
DispatchEnvelopeClaim::TASKS_INPUT_REQUIRED
} else {
DispatchEnvelopeClaim::NONE
},
)
},
Err(e) => (
error_response(
id,
crate::types::protocol::error_codes::INTERNAL_ERROR,
e.to_string(),
),
DispatchEnvelopeClaim::NONE,
),
};
}
(
error_response(
id,
crate::types::protocol::error_codes::METHOD_NOT_FOUND,
TASKS_NOT_ENABLED.to_string(),
),
DispatchEnvelopeClaim::NONE,
)
}
async fn route_tasks_list(
&self,
id: RequestId,
params: &crate::types::tasks::ListTasksRequest,
owner_id: &str,
) -> JSONRPCResponse {
if let Some(store) = self.task_store {
match store.list(owner_id, params.cursor.as_deref()).await {
Ok((tasks, next_cursor)) => {
let mut result = crate::types::tasks::ListTasksResult::new(tasks);
if let Some(cursor) = next_cursor {
result = result.with_next_cursor(cursor);
}
success_response(id, serde_json::to_value(result).unwrap_or_default())
},
Err(e) => error_response(
id,
crate::types::protocol::error_codes::INTERNAL_ERROR,
e.to_string(),
),
}
} else if let Some(task_router) = self.task_router {
match task_router
.handle_tasks_list(serde_json::to_value(params).unwrap_or_default(), owner_id)
.await
{
Ok(result) => success_response(id, result),
Err(e) => error_response(
id,
crate::types::protocol::error_codes::INTERNAL_ERROR,
e.to_string(),
),
}
} else {
error_response(
id,
crate::types::protocol::error_codes::METHOD_NOT_FOUND,
TASKS_NOT_ENABLED.to_string(),
)
}
}
async fn route_tasks_cancel(
&self,
id: RequestId,
params: &crate::types::tasks::CancelTaskRequest,
owner_id: &str,
era: Option<crate::types::protocol::Era>,
) -> JSONRPCResponse {
let v1 = is_v1_task_era(era);
if let Some(store) = self.task_store {
return match store.cancel(¶ms.task_id, owner_id).await {
Ok(task) if v1 => {
let result = crate::types::tasks::CancelTaskResult::new(task);
success_response(id, serde_json::to_value(result).unwrap_or_default())
},
Ok(_) => success_response(id, Value::Object(serde_json::Map::new())),
Err(e) => store_error_response(id, &e, era),
};
}
if let Some(task_router) = self.task_router {
return match task_router
.handle_tasks_cancel(serde_json::to_value(params).unwrap_or_default(), owner_id)
.await
{
Ok(result) if v1 => success_response(id, result),
Ok(_) => success_response(id, Value::Object(serde_json::Map::new())),
Err(e) => error_response(
id,
crate::types::protocol::error_codes::INTERNAL_ERROR,
e.to_string(),
),
};
}
error_response(
id,
crate::types::protocol::error_codes::METHOD_NOT_FOUND,
TASKS_NOT_ENABLED.to_string(),
)
}
pub(crate) async fn route_tasks_endpoint(
&self,
id: RequestId,
request: &ClientRequest,
auth_context: Option<&AuthContext>,
protocol_context: Option<&crate::types::protocol::ProtocolContext>,
) -> (JSONRPCResponse, DispatchEnvelopeClaim) {
let era = protocol_context.map(|context| context.era);
if self.has_task_backend() {
if let Some(method) = Self::retired_method(request, era) {
return (retired_on_v2(id, method), DispatchEnvelopeClaim::NONE);
}
if !Self::declares_tasks_extension(protocol_context, era) {
return (
missing_tasks_declaration_refusal(id),
DispatchEnvelopeClaim::NONE,
);
}
}
let owner_id = match self.resolve_owner(auth_context, era) {
OwnerBinding::Owner(owner) => owner,
OwnerBinding::Refused if !self.has_task_backend() => {
V1_UNAUTHENTICATED_OWNER.to_string()
},
OwnerBinding::Refused => {
return (
authentication_required(id, Self::method_of(request)),
DispatchEnvelopeClaim::NONE,
);
},
};
match request {
ClientRequest::TasksGet(params) => {
self.route_tasks_get(id, params, &owner_id, era).await
},
ClientRequest::TasksResult(params) => (
self.handle_tasks_result(id, params, &owner_id, era).await,
DispatchEnvelopeClaim::NONE,
),
ClientRequest::TasksList(params) => (
self.route_tasks_list(id, params, &owner_id).await,
DispatchEnvelopeClaim::NONE,
),
ClientRequest::TasksCancel(params) => (
self.route_tasks_cancel(id, params, &owner_id, era).await,
DispatchEnvelopeClaim::NONE,
),
_ => (
error_response(
id,
crate::types::protocol::error_codes::METHOD_NOT_FOUND,
NOT_A_TASKS_METHOD.to_string(),
),
DispatchEnvelopeClaim::NONE,
),
}
}
pub(crate) async fn route_tasks_update(
&self,
id: RequestId,
params: &Value,
auth_context: Option<&AuthContext>,
protocol_context: Option<&crate::types::protocol::ProtocolContext>,
) -> JSONRPCResponse {
let era = protocol_context.map(|context| context.era);
if is_v1_task_era(era) {
return error_response(
id,
crate::types::protocol::error_codes::METHOD_NOT_FOUND,
format!("{TASKS_UPDATE_METHOD} {V1_TASKS_UPDATE_ABSENT}"),
);
}
if !self.has_task_backend() {
return error_response(
id,
crate::types::protocol::error_codes::METHOD_NOT_FOUND,
TASKS_NOT_ENABLED.to_string(),
);
}
if !Self::declares_tasks_extension(protocol_context, era) {
return missing_tasks_declaration_refusal(id);
}
let owner_id = match self.resolve_owner(auth_context, era) {
OwnerBinding::Owner(owner) => owner,
OwnerBinding::Refused => {
return authentication_required(id, TASKS_UPDATE_METHOD);
},
};
debug_assert!(
!owner_id.is_empty() || !self.has_auth_provider,
"an empty owner is the anonymous principal, which only a server with no auth \
provider may bind"
);
let update = match Self::parse_tasks_update_params(params) {
Ok(update) => update,
Err(message) => {
return error_response(
id,
crate::types::protocol::error_codes::INVALID_PARAMS,
message.to_string(),
)
},
};
if let Err(violation) = check_input_responses_map_bounds(update.input_responses) {
return error_response(
id,
crate::types::protocol::error_codes::INVALID_PARAMS,
violation.to_string(),
);
}
self.deliver_tasks_update(id, params, &update, &owner_id, era)
.await
}
fn parse_tasks_update_params(
params: &Value,
) -> std::result::Result<TasksUpdateParams<'_>, &'static str> {
let Some(task_id) = crate::types::mrtr::logical_name_of(TASKS_UPDATE_METHOD, params) else {
return Err(TASKS_UPDATE_MALFORMED_PARAMS);
};
let Some(input_responses) = params.get(INPUT_RESPONSES_KEY).and_then(Value::as_object)
else {
return Err(TASKS_UPDATE_MISSING_INPUT_RESPONSES);
};
Ok(TasksUpdateParams {
task_id,
input_responses,
})
}
fn decode_inputs_against_record(
raw: &serde_json::Map<String, Value>,
snapshot: &TaskInputSnapshot,
) -> std::result::Result<InputResponses, InputResponseTypingError> {
let mut typed = InputResponses::new();
for (key, value) in raw {
let Some((recorded_key, request)) = snapshot.input_requests.get_key_value(key) else {
continue;
};
let kind = request.kind();
let response = InputResponse::decode_for(kind, value.clone()).map_err(|_| {
InputResponseTypingError::KindMismatch {
key: recorded_key.clone(),
expected: kind,
}
})?;
typed.insert(recorded_key.clone(), response);
}
Ok(typed)
}
async fn deliver_tasks_update(
&self,
id: RequestId,
raw_params: &Value,
update: &TasksUpdateParams<'_>,
owner_id: &str,
era: Option<crate::types::protocol::Era>,
) -> JSONRPCResponse {
if let Some(response) = self
.deliver_update_through_store(id.clone(), update, owner_id, era)
.await
{
return response;
}
let Some(task_router) = self.task_router else {
return error_response(
id,
crate::types::protocol::error_codes::METHOD_NOT_FOUND,
TASKS_NOT_ENABLED.to_string(),
);
};
match task_router
.handle_tasks_update(raw_params.clone(), owner_id)
.await
{
Ok(_) => update_ack(id),
Err(e) => error_response(
id,
crate::types::protocol::error_codes::INTERNAL_ERROR,
e.to_string(),
),
}
}
async fn deliver_update_through_store(
&self,
id: RequestId,
update: &TasksUpdateParams<'_>,
owner_id: &str,
era: Option<crate::types::protocol::Era>,
) -> Option<JSONRPCResponse> {
let store = self.task_store.as_ref()?;
if !store.supports_inputs() {
return None;
}
let snapshot = match store.task_input_snapshot(&update.task_id, owner_id).await {
Ok(snapshot) => snapshot,
Err(e) => return self.store_error_or_fall_through(id, &e, era),
};
let typed = match Self::decode_inputs_against_record(update.input_responses, &snapshot) {
Ok(typed) => typed,
Err(refusal) => {
return Some(error_response(
id,
crate::types::protocol::error_codes::INVALID_PARAMS,
refusal.to_string(),
))
},
};
match store
.deliver_task_inputs(&update.task_id, owner_id, typed)
.await
{
Ok(_delivery) => Some(update_ack(id)),
Err(e) => self.store_error_or_fall_through(id, &e, era),
}
}
fn store_error_or_fall_through(
&self,
id: RequestId,
error: &TaskStoreError,
era: Option<crate::types::protocol::Era>,
) -> Option<JSONRPCResponse> {
if matches!(error, TaskStoreError::NotFound { .. }) && self.task_router.is_some() {
return None;
}
Some(store_error_response(id, error, era))
}
fn declares_tasks_extension(
protocol_context: Option<&crate::types::protocol::ProtocolContext>,
era: Option<crate::types::protocol::Era>,
) -> bool {
if is_v1_task_era(era) {
return true;
}
protocol_context
.and_then(|context| context.client_capabilities.as_ref())
.and_then(|capabilities| capabilities.extensions.as_ref())
.is_some_and(|extensions| {
extensions.contains_key(crate::types::capabilities::TASKS_EXTENSION_KEY)
})
}
fn retired_method(
request: &ClientRequest,
era: Option<crate::types::protocol::Era>,
) -> Option<&'static str> {
match request {
ClientRequest::TasksList(_) if !tasks_list_serves_on_era(era) => {
Some(TASKS_LIST_METHOD)
},
ClientRequest::TasksResult(_) if !tasks_result_serves_on_era(era) => {
Some(TASKS_RESULT_METHOD)
},
_ => None,
}
}
fn method_of(request: &ClientRequest) -> &'static str {
match request {
ClientRequest::TasksGet(_) => crate::types::mrtr::TASKS_GET_METHOD,
ClientRequest::TasksResult(_) => TASKS_RESULT_METHOD,
ClientRequest::TasksList(_) => TASKS_LIST_METHOD,
ClientRequest::TasksCancel(_) => crate::types::mrtr::TASKS_CANCEL_METHOD,
_ => NOT_A_TASKS_METHOD,
}
}
}
#[cfg(test)]
#[allow(clippy::doc_markdown, clippy::unnecessary_wraps)]
mod gate_tests {
use super::*;
use crate::server::task_store::InMemoryTaskStore;
use crate::types::protocol::{Era, ProtocolContext};
use crate::types::RequestId;
fn store_backend() -> Option<Arc<dyn TaskStore>> {
Some(Arc::new(InMemoryTaskStore::new()) as Arc<dyn TaskStore>)
}
fn task_shaped_value() -> Value {
serde_json::json!({
"taskId": "tool-fabricated",
"status": "completed",
"result": { "content": [{ "type": "text", "text": "done" }] }
})
}
fn id() -> RequestId {
RequestId::from(1i64)
}
const fn v1_trigger(task_field_present: bool) -> CreateTrigger {
CreateTrigger::V1TaskField { task_field_present }
}
fn context(era: Era, declares: bool) -> ProtocolContext {
let version = match era {
Era::V2 => crate::types::protocol::PROTOCOL_VERSION_2026_07_28,
Era::V1 => crate::LATEST_PROTOCOL_VERSION,
};
let context = ProtocolContext::new(era, crate::types::ProtocolVersion(version.to_string()));
if !declares {
return context;
}
let mut extensions = HashMap::new();
extensions.insert(TASKS_EXTENSION_KEY.to_string(), tasks_extension_value());
context.with_client_capabilities(crate::types::ClientCapabilities {
extensions: Some(extensions),
..crate::types::ClientCapabilities::default()
})
}
fn resolved_trigger(era: Era, task_field_present: bool, declares: bool) -> CreateTrigger {
let context = context(era, declares);
CreateTrigger::resolve(Some(era), task_field_present, Some(&context))
}
#[tokio::test]
async fn gate_rejects_when_not_task_requested() {
let store = store_backend();
let router = None;
let dispatch = TaskDispatch {
task_store: &store,
task_router: &router,
has_auth_provider: false,
};
let value = task_shaped_value();
let out = dispatch
.maybe_build_task_created(
id(),
&value,
Some(TaskSupport::Required),
v1_trigger(false),
None,
None,
)
.await;
assert!(out.is_none(), "task_requested=false must yield None");
}
#[tokio::test]
async fn gate_rejects_when_no_backend() {
let store = None;
let router = None;
let dispatch = TaskDispatch {
task_store: &store,
task_router: &router,
has_auth_provider: false,
};
let value = task_shaped_value();
let out = dispatch
.maybe_build_task_created(
id(),
&value,
Some(TaskSupport::Required),
v1_trigger(true),
None,
None,
)
.await;
assert!(out.is_none(), "no backend must yield None");
}
#[tokio::test]
async fn gate_rejects_forbidden_no_error_leak() {
let store = store_backend();
let router = None;
let dispatch = TaskDispatch {
task_store: &store,
task_router: &router,
has_auth_provider: false,
};
let value = task_shaped_value();
let out = dispatch
.maybe_build_task_created(
id(),
&value,
Some(TaskSupport::Forbidden),
v1_trigger(true),
None,
None,
)
.await;
assert!(out.is_none(), "Forbidden must yield None, never an error");
}
#[tokio::test]
async fn gate_rejects_no_task_support() {
let store = store_backend();
let router = None;
let dispatch = TaskDispatch {
task_store: &store,
task_router: &router,
has_auth_provider: false,
};
let value = task_shaped_value();
let out = dispatch
.maybe_build_task_created(id(), &value, None, v1_trigger(true), None, None)
.await;
assert!(out.is_none(), "no task_support must yield None");
}
#[tokio::test]
async fn gate_rejects_non_task_shaped_value() {
let store = store_backend();
let router = None;
let dispatch = TaskDispatch {
task_store: &store,
task_router: &router,
has_auth_provider: false,
};
let value = serde_json::json!({ "foo": "bar" });
let out = dispatch
.maybe_build_task_created(
id(),
&value,
Some(TaskSupport::Required),
v1_trigger(true),
None,
None,
)
.await;
assert!(out.is_none(), "non-task-shaped value must yield None");
}
fn assert_store_minted(resp: &JSONRPCResponse) {
let ResponsePayload::Result(value) = &resp.payload else {
panic!("expected a success result envelope");
};
let wire_task_id = value
.get("task")
.and_then(|t| t.get("taskId"))
.and_then(Value::as_str)
.expect("task.taskId present");
let meta_id = value
.get("_meta")
.and_then(|m| m.get(RELATED_TASK_META_KEY))
.and_then(|r| r.get("taskId"))
.and_then(Value::as_str)
.expect("_meta.relatedTask.taskId present");
assert_eq!(
wire_task_id, meta_id,
"three-way invariant: task.taskId == _meta.relatedTask.taskId"
);
assert_ne!(
wire_task_id, "tool-fabricated",
"wire id must be store-minted, not the tool-fabricated id"
);
}
#[tokio::test]
async fn gate_accepts_optional_task_shaped() {
let store = store_backend();
let router = None;
let dispatch = TaskDispatch {
task_store: &store,
task_router: &router,
has_auth_provider: false,
};
let value = task_shaped_value();
let out = dispatch
.maybe_build_task_created(
id(),
&value,
Some(TaskSupport::Optional),
v1_trigger(true),
None,
None,
)
.await;
let (resp, claim) = out.expect("Optional + task-shaped must yield Some");
assert_eq!(claim, DispatchEnvelopeClaim::NONE);
assert_store_minted(&resp);
}
#[tokio::test]
async fn gate_accepts_required_task_shaped() {
let store = store_backend();
let router = None;
let dispatch = TaskDispatch {
task_store: &store,
task_router: &router,
has_auth_provider: false,
};
let value = task_shaped_value();
let out = dispatch
.maybe_build_task_created(
id(),
&value,
Some(TaskSupport::Required),
v1_trigger(true),
None,
None,
)
.await;
let (resp, claim) = out.expect("Required + task-shaped must yield Some");
assert_eq!(claim, DispatchEnvelopeClaim::NONE);
assert_store_minted(&resp);
}
fn gate_with(trigger: CreateTrigger) -> CreateGate {
let store = store_backend();
let router = None;
let dispatch = TaskDispatch {
task_store: &store,
task_router: &router,
has_auth_provider: false,
};
dispatch.create_gate(trigger, Some(TaskSupport::Required), &task_shaped_value())
}
#[test]
fn v2_gate_opens_on_a_client_declaration() {
assert_eq!(
gate_with(resolved_trigger(Era::V2, false, true)),
CreateGate::Create,
"a declaring v2 client must be able to receive a task handle"
);
}
#[test]
fn v2_gate_rejects_a_non_declaring_client() {
assert_eq!(
gate_with(resolved_trigger(Era::V2, false, false)),
CreateGate::Closed,
"a non-declaring v2 client must never receive a task handle"
);
}
#[test]
fn v2_gate_ignores_the_v1_task_field() {
assert_eq!(
gate_with(resolved_trigger(Era::V2, true, false)),
CreateGate::Closed,
"the v1 `task` field must not open the v2 gate"
);
}
#[test]
fn v1_gate_still_requires_the_task_field() {
assert_eq!(
gate_with(resolved_trigger(Era::V1, true, false)),
CreateGate::Create,
"v1 creation is triggered by the `task` field, exactly as before"
);
assert_eq!(
gate_with(resolved_trigger(Era::V1, false, false)),
CreateGate::Closed,
"v1 without a `task` field must still fall through"
);
}
#[test]
fn v1_gate_ignores_a_client_declaration() {
assert_eq!(
gate_with(resolved_trigger(Era::V1, false, true)),
CreateGate::Closed,
"a declaration must not open the v1 gate"
);
}
#[test]
fn a_gate_open_but_unshaped_value_is_distinguishable_from_a_closed_gate() {
let store = store_backend();
let router = None;
let dispatch = TaskDispatch {
task_store: &store,
task_router: &router,
has_auth_provider: false,
};
let unshaped = serde_json::json!({ "foo": "bar" });
assert_eq!(
dispatch.create_gate(v1_trigger(true), Some(TaskSupport::Required), &unshaped),
CreateGate::NotTaskShaped
);
assert_eq!(
dispatch.create_gate(v1_trigger(false), Some(TaskSupport::Required), &unshaped),
CreateGate::Closed
);
}
}
#[cfg(test)]
mod owner_binding_tests {
use super::*;
use crate::server::auth::AuthContext;
use crate::server::core::ANONYMOUS_PRINCIPAL;
use crate::types::protocol::Era;
fn bind(subject: Option<&str>, has_auth_provider: bool, era: Option<Era>) -> OwnerBinding {
let store = None;
let router = None;
let dispatch = TaskDispatch {
task_store: &store,
task_router: &router,
has_auth_provider,
};
let auth = subject.map(AuthContext::new);
dispatch.resolve_owner(auth.as_ref(), era)
}
#[test]
fn v2_owner_is_the_authenticated_subject() {
for has_auth_provider in [true, false] {
assert_eq!(
bind(Some("user-alice"), has_auth_provider, Some(Era::V2)),
OwnerBinding::Owner("user-alice".to_string()),
"row 1 must bind the OAuth subject verbatim, \
has_auth_provider={has_auth_provider}"
);
}
}
#[test]
fn v2_unauthenticated_with_auth_provider_is_refused() {
assert_eq!(
bind(None, true, Some(Era::V2)),
OwnerBinding::Refused,
"row 2 must refuse: no subject on an auth-configured server binds NO owner"
);
}
#[test]
fn v2_unauthenticated_without_auth_provider_is_anonymous() {
assert_eq!(
bind(None, false, Some(Era::V2)),
OwnerBinding::Owner(ANONYMOUS_PRINCIPAL.to_string()),
"row 3 must bind the NAMED anonymous principal, not refuse"
);
}
#[test]
fn the_v1_and_v2_unauthenticated_buckets_are_different_keys() {
assert_ne!(
ANONYMOUS_PRINCIPAL, V1_UNAUTHENTICATED_OWNER,
"the v1 and v2 unauthenticated owners must remain distinct key prefixes"
);
}
#[test]
fn v1_unauthenticated_owner_is_still_local() {
for era in [Some(Era::V1), None] {
for has_auth_provider in [true, false] {
assert_eq!(
bind(None, has_auth_provider, era),
OwnerBinding::Owner(V1_UNAUTHENTICATED_OWNER.to_string()),
"v1 owner binding is frozen and NEVER refuses: \
era={era:?}, has_auth_provider={has_auth_provider}"
);
}
}
}
#[test]
fn v1_authenticated_owner_is_still_the_subject() {
assert_eq!(
bind(Some("user-bob"), true, Some(Era::V1)),
OwnerBinding::Owner("user-bob".to_string()),
"v1 store-path owner binding is the OAuth subject, unchanged"
);
}
#[test]
fn the_v1_migration_warn_fires_once_per_unauthenticated_resolution() {
let counter = WarnCounter::default();
let counts = Arc::clone(&counter.warnings);
tracing::subscriber::with_default(counter, || {
assert_eq!(
bind(None, false, Some(Era::V1)),
OwnerBinding::Owner(V1_UNAUTHENTICATED_OWNER.to_string())
);
});
assert_eq!(
counts.load(std::sync::atomic::Ordering::SeqCst),
1,
"exactly one migration warn per unauthenticated v1 owner resolution"
);
let counter = WarnCounter::default();
let counts = Arc::clone(&counter.warnings);
tracing::subscriber::with_default(counter, || {
assert_eq!(
bind(Some("user-carol"), false, Some(Era::V1)),
OwnerBinding::Owner("user-carol".to_string())
);
});
assert_eq!(
counts.load(std::sync::atomic::Ordering::SeqCst),
0,
"an AUTHENTICATED v1 caller is not in the shared bucket and must not be warned about"
);
}
#[test]
fn the_migration_warn_is_v1_only() {
let counter = WarnCounter::default();
let counts = Arc::clone(&counter.warnings);
tracing::subscriber::with_default(counter, || {
assert_eq!(bind(None, true, Some(Era::V2)), OwnerBinding::Refused);
assert_eq!(
bind(None, false, Some(Era::V2)),
OwnerBinding::Owner(ANONYMOUS_PRINCIPAL.to_string())
);
});
assert_eq!(
counts.load(std::sync::atomic::Ordering::SeqCst),
0,
"the D-10 migration warn is about v1's shared bucket and must not fire on v2"
);
}
#[derive(Default)]
struct WarnCounter {
warnings: Arc<std::sync::atomic::AtomicUsize>,
}
impl tracing::Subscriber for WarnCounter {
fn enabled(&self, metadata: &tracing::Metadata<'_>) -> bool {
*metadata.level() == tracing::Level::WARN
}
fn new_span(&self, _span: &tracing::span::Attributes<'_>) -> tracing::Id {
tracing::Id::from_u64(1)
}
fn record(&self, _span: &tracing::Id, _values: &tracing::span::Record<'_>) {}
fn record_follows_from(&self, _span: &tracing::Id, _follows: &tracing::Id) {}
fn event(&self, event: &tracing::Event<'_>) {
if *event.metadata().level() == tracing::Level::WARN {
self.warnings
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}
}
fn enter(&self, _span: &tracing::Id) {}
fn exit(&self, _span: &tracing::Id) {}
}
}
#[cfg(test)]
#[allow(clippy::unnecessary_wraps, clippy::ref_option)]
mod era_gate_tests {
use super::*;
use crate::server::task_store::InMemoryTaskStore;
use crate::types::protocol::error_codes::{METHOD_NOT_FOUND, V1_TASK_PENDING};
use crate::types::protocol::Era;
use crate::types::RequestId;
const ERAS: [(Option<Era>, bool); 3] =
[(Some(Era::V1), true), (None, true), (Some(Era::V2), false)];
fn id() -> RequestId {
RequestId::from(1i64)
}
fn store_backend() -> Option<Arc<dyn TaskStore>> {
Some(Arc::new(InMemoryTaskStore::new()) as Arc<dyn TaskStore>)
}
fn list_request() -> ClientRequest {
ClientRequest::TasksList(crate::types::tasks::ListTasksRequest { cursor: None })
}
fn result_request() -> ClientRequest {
ClientRequest::TasksResult(crate::types::tasks::GetTaskPayloadRequest {
task_id: "absent".to_string(),
})
}
fn tasks_declaring_capabilities() -> crate::types::ClientCapabilities {
let mut extensions = HashMap::new();
extensions.insert(TASKS_EXTENSION_KEY.to_string(), tasks_extension_value());
crate::types::ClientCapabilities {
extensions: Some(extensions),
..crate::types::ClientCapabilities::default()
}
}
fn context_for(era: Era) -> crate::types::protocol::ProtocolContext {
let version = match era {
Era::V2 => crate::types::protocol::PROTOCOL_VERSION_2026_07_28,
Era::V1 => crate::LATEST_PROTOCOL_VERSION,
};
crate::types::protocol::ProtocolContext::new(
era,
crate::types::ProtocolVersion(version.to_string()),
)
.with_client_capabilities(tasks_declaring_capabilities())
}
async fn route(
store: &Option<Arc<dyn TaskStore>>,
request: &ClientRequest,
era: Option<Era>,
) -> JSONRPCResponse {
let router = None;
let dispatch = TaskDispatch {
task_store: store,
task_router: &router,
has_auth_provider: false,
};
let context = era.map(context_for);
dispatch
.route_tasks_endpoint(id(), request, None, context.as_ref())
.await
.0
}
fn error_of(response: &JSONRPCResponse) -> Option<(i32, String)> {
match &response.payload {
ResponsePayload::Error(error) => Some((error.code, error.message.clone())),
ResponsePayload::Result(_) => None,
}
}
#[test]
fn tasks_list_era_truth_table() {
for (era, expected) in ERAS {
assert_eq!(
tasks_list_serves_on_era(era),
expected,
"tasks/list serving decision for era {era:?}"
);
}
}
#[test]
fn tasks_result_era_truth_table() {
for (era, expected) in ERAS {
assert_eq!(
tasks_result_serves_on_era(era),
expected,
"tasks/result serving decision for era {era:?}"
);
}
}
#[tokio::test]
async fn v2_tasks_list_is_retired() {
let store = store_backend();
let response = route(&store, &list_request(), Some(Era::V2)).await;
let (code, message) = error_of(&response).expect("a v2 tasks/list must be refused");
assert_eq!(code, METHOD_NOT_FOUND, "message was {message}");
assert!(
message.starts_with(TASKS_LIST_METHOD) && message.contains(V2_TASKS_METHOD_RETIRED),
"the refusal must name the method AND the retirement: {message}"
);
}
#[tokio::test]
async fn v2_tasks_result_is_retired() {
let store = store_backend();
let response = route(&store, &result_request(), Some(Era::V2)).await;
let (code, message) = error_of(&response).expect("a v2 tasks/result must be refused");
assert_eq!(code, METHOD_NOT_FOUND, "message was {message}");
assert_ne!(
code, V1_TASK_PENDING,
"protocol version 2026-07-28 MUST NOT emit -32002: {message}"
);
assert!(
message.starts_with(TASKS_RESULT_METHOD) && message.contains(V2_TASKS_METHOD_RETIRED),
"the refusal must name the method AND the retirement: {message}"
);
}
#[tokio::test]
async fn v1_list_and_result_are_unchanged() {
let store = store_backend();
let listed = route(&store, &list_request(), Some(Era::V1)).await;
let ResponsePayload::Result(value) = &listed.payload else {
panic!("a v1 tasks/list must still serve: {:?}", listed.payload);
};
assert!(
value.get("tasks").is_some_and(Value::is_array),
"a v1 tasks/list result still carries the tasks array: {value}"
);
let pending = route(&store, &result_request(), Some(Era::V1)).await;
assert_eq!(
error_of(&pending),
Some((
V1_TASK_PENDING,
"task result not available: task not completed".to_string()
)),
"the v1 pending refusal is FROZEN, code and message"
);
}
#[tokio::test]
async fn a_backendless_v2_server_is_not_told_the_methods_were_retired() {
let store = None;
let listed = route(&store, &list_request(), Some(Era::V2)).await;
let (list_code, list_message) = error_of(&listed).expect("no backend refuses tasks/list");
assert_eq!(list_code, METHOD_NOT_FOUND, "message was {list_message}");
assert_eq!(list_message, "Tasks not enabled");
let resulted = route(&store, &result_request(), Some(Era::V2)).await;
let (result_code, result_message) =
error_of(&resulted).expect("no backend refuses tasks/result");
assert_eq!(
result_code, METHOD_NOT_FOUND,
"message was {result_message}"
);
assert_eq!(result_message, "tasks/result not supported");
for message in [&list_message, &result_message] {
assert!(
!message.contains(V2_TASKS_METHOD_RETIRED),
"a no-backend refusal must not claim a retirement: {message}"
);
}
assert_ne!(
list_message, result_message,
"the two no-backend refusals are themselves distinguishable"
);
}
}
#[cfg(test)]
mod capability_rule_tests {
use super::*;
fn no_tools() -> HashMap<String, ToolInfo> {
HashMap::new()
}
fn tasks_entry(capabilities: &ServerCapabilities) -> Option<&Value> {
capabilities
.extensions
.as_ref()
.and_then(|extensions| extensions.get(TASKS_EXTENSION_KEY))
}
#[test]
fn capability_rule_advertises_the_tasks_extension_when_a_backend_exists() {
let mut capabilities = ServerCapabilities::default();
apply_tasks_capability_rule(&mut capabilities, &no_tools(), true).unwrap();
assert_eq!(
tasks_entry(&capabilities),
Some(&serde_json::json!({})),
"a backend-configured server must advertise the tasks extension as \
the EMPTY OBJECT (D-03): {capabilities:?}"
);
assert!(
capabilities.tasks.is_some(),
"the v1 tasks capability must still be auto-advertised: {capabilities:?}"
);
}
#[test]
fn capability_rule_preserves_an_explicitly_configured_tasks_extension_value() {
let explicit = serde_json::json!({ "io.example/nonconformant": true });
let mut capabilities = ServerCapabilities::default();
let mut extensions = HashMap::new();
extensions.insert(TASKS_EXTENSION_KEY.to_string(), explicit.clone());
capabilities.extensions = Some(extensions);
apply_tasks_capability_rule(&mut capabilities, &no_tools(), true).unwrap();
assert_eq!(
serde_json::to_string(tasks_entry(&capabilities).expect("entry present")).unwrap(),
serde_json::to_string(&explicit).unwrap(),
"an explicitly configured extension value must survive the rule \
byte-unchanged: {capabilities:?}"
);
}
#[test]
fn capability_rule_advertises_nothing_without_a_backend() {
let mut capabilities = ServerCapabilities::default();
apply_tasks_capability_rule(&mut capabilities, &no_tools(), false).unwrap();
assert!(
capabilities.tasks.is_none(),
"no backend must mean no v1 tasks capability: {capabilities:?}"
);
assert_eq!(
tasks_entry(&capabilities),
None,
"no backend must mean no v2 extension entry: {capabilities:?}"
);
assert!(
capabilities.extensions.is_none(),
"and the rule must not manufacture an empty extensions map: {capabilities:?}"
);
}
#[test]
fn capability_rule_leaves_an_unrelated_extensions_key_intact() {
let mut capabilities = ServerCapabilities::default();
let mut extensions = HashMap::new();
extensions.insert(
"io.example/experimental".to_string(),
serde_json::json!({ "enabled": true }),
);
capabilities.extensions = Some(extensions);
apply_tasks_capability_rule(&mut capabilities, &no_tools(), true).unwrap();
let extensions = capabilities.extensions.as_ref().expect("map present");
assert_eq!(
extensions.get("io.example/experimental"),
Some(&serde_json::json!({ "enabled": true })),
"an unrelated extensions key must survive: {extensions:?}"
);
assert_eq!(
extensions.get(TASKS_EXTENSION_KEY),
Some(&serde_json::json!({})),
"and the tasks entry lands alongside it: {extensions:?}"
);
}
}
#[cfg(test)]
mod v2_shape_tests {
use super::*;
use crate::server::task_store::InMemoryTaskStore;
use crate::types::protocol::error_codes::{INTERNAL_ERROR, INVALID_PARAMS};
use crate::types::protocol::Era;
fn id() -> RequestId {
RequestId::from(1i64)
}
fn all_store_errors() -> Vec<TaskStoreError> {
vec![
TaskStoreError::NotFound {
task_id: "task-abc".to_string(),
},
TaskStoreError::Expired {
task_id: "task-abc".to_string(),
},
TaskStoreError::InvalidTransition {
task_id: "task-abc".to_string(),
from: TaskStatus::Completed,
to: TaskStatus::Working,
},
TaskStoreError::Internal {
message: "backend unavailable".to_string(),
},
]
}
fn error_of(response: &JSONRPCResponse) -> (i32, String) {
match &response.payload {
ResponsePayload::Error(error) => (error.code, error.message.clone()),
ResponsePayload::Result(value) => {
panic!("expected an error response, got a result: {value}")
},
}
}
fn result_of(response: &JSONRPCResponse) -> Value {
match &response.payload {
ResponsePayload::Result(value) => value.clone(),
ResponsePayload::Error(error) => {
panic!(
"expected a success result, got {}: {}",
error.code, error.message
)
},
}
}
#[test]
fn v1_store_errors_are_all_internal_error() {
for era in [Some(Era::V1), None] {
for error in all_store_errors() {
let (code, message) = error_of(&store_error_response(id(), &error, era));
assert_eq!(code, INTERNAL_ERROR, "{era:?} / {error}");
assert_eq!(message, error.to_string(), "{era:?} / {error}");
}
}
}
#[test]
fn v2_maps_only_not_found_and_expired_to_invalid_params() {
let expected = [
(INVALID_PARAMS, true),
(INVALID_PARAMS, true),
(INTERNAL_ERROR, false),
(INTERNAL_ERROR, false),
];
for (error, (code, is_not_found)) in all_store_errors().into_iter().zip(expected) {
let (actual_code, message) =
error_of(&store_error_response(id(), &error, Some(Era::V2)));
assert_eq!(actual_code, code, "{error}");
if is_not_found {
assert_eq!(message, V2_TASK_NOT_FOUND_MESSAGE, "{error}");
} else {
assert_eq!(message, error.to_string(), "{error}");
}
}
}
#[tokio::test]
async fn the_v2_not_found_answer_is_identical_for_absent_and_wrong_owner() {
let store = InMemoryTaskStore::new();
let owned = store.create("owner-a", None).await.expect("creates");
let wrong_owner = store
.get(&owned.task_id, "owner-b")
.await
.expect_err("another owner must not read it");
let absent = store
.get("no-such-task", "owner-b")
.await
.expect_err("an absent task must not be found");
let a = error_of(&store_error_response(id(), &wrong_owner, Some(Era::V2)));
let b = error_of(&store_error_response(id(), &absent, Some(Era::V2)));
assert_eq!(a, b, "the two refusals must be indistinguishable");
assert!(
!a.1.contains(&owned.task_id) && !a.1.contains("no-such-task"),
"the refusal must not render a task id back: {}",
a.1
);
}
#[test]
fn the_v2_not_found_message_never_echoes_the_task_id() {
for task_id in [
"task-abc",
"\n2026-01-01 ERROR forged log line",
"../../etc/passwd",
] {
let error = TaskStoreError::NotFound {
task_id: task_id.to_string(),
};
let (_, message) = error_of(&store_error_response(id(), &error, Some(Era::V2)));
assert!(
!message.contains(task_id),
"the id leaked into the refusal: {message}"
);
assert_eq!(message, V2_TASK_NOT_FOUND_MESSAGE);
}
}
fn sample_task() -> Task {
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)
}
#[test]
fn the_v2_create_body_is_flat() {
let value = v2_create_result_value(&sample_task(), "t-1");
assert_eq!(value.get("taskId").and_then(Value::as_str), Some("t-1"));
assert!(value.get("task").is_none(), "v2 must not wrap: {value}");
assert_eq!(value.get("ttlMs").and_then(Value::as_u64), Some(60_000));
assert_eq!(
value.get("pollIntervalMs").and_then(Value::as_u64),
Some(2500)
);
assert!(
value.get("_meta").is_some(),
"the relatedTask envelope stays"
);
}
#[test]
fn the_v1_create_body_is_still_nested() {
let value = v1_create_result_value(&sample_task(), "t-1");
let task = value.get("task").expect("v1 wraps under `task`");
assert_eq!(task.get("taskId").and_then(Value::as_str), Some("t-1"));
assert_eq!(task.get("ttl").and_then(Value::as_u64), Some(60_000));
assert_eq!(task.get("pollInterval").and_then(Value::as_u64), Some(2500));
let raw = value.to_string();
assert!(
!raw.contains("ttlMs"),
"a v2 spelling leaked into v1: {raw}"
);
assert!(
!raw.contains("pollIntervalMs"),
"a v2 spelling leaked into v1: {raw}"
);
}
#[tokio::test]
async fn the_create_claim_is_era_split() {
let store = Some(Arc::new(InMemoryTaskStore::new()) as Arc<dyn TaskStore>);
let router = None;
let dispatch = TaskDispatch {
task_store: &store,
task_router: &router,
has_auth_provider: false,
};
let value = serde_json::json!({
"taskId": "tool-fabricated",
"status": "working",
"createdAt": "2026-07-28T00:00:00Z",
"lastUpdatedAt": "2026-07-28T00:00:00Z"
});
let (_, v1_claim) = dispatch
.build_task_created_response(id(), value.clone(), None, Some(Era::V1))
.await;
assert_eq!(v1_claim, DispatchEnvelopeClaim::NONE);
let (response, v2_claim) = dispatch
.build_task_created_response(id(), value, None, Some(Era::V2))
.await;
assert_eq!(v2_claim, DispatchEnvelopeClaim::TASK_CREATED);
assert_eq!(
v2_claim.disposition.as_wire_str(),
"task",
"the create claim is the ONLY source of `resultType: \"task\"`"
);
assert!(result_of(&response).get("taskId").is_some());
}
#[tokio::test]
async fn the_get_claim_is_input_required_only() {
let store_impl = Arc::new(InMemoryTaskStore::new());
let store = Some(store_impl.clone() as Arc<dyn TaskStore>);
let router = None;
let dispatch = TaskDispatch {
task_store: &store,
task_router: &router,
has_auth_provider: false,
};
let task = store_impl.create("owner-a", None).await.expect("creates");
let params = crate::types::tasks::GetTaskRequest {
task_id: task.task_id.clone(),
};
let (response, claim) = dispatch
.route_tasks_get(id(), ¶ms, "owner-a", Some(Era::V2))
.await;
assert_eq!(claim, DispatchEnvelopeClaim::NONE);
assert!(result_of(&response).get("inputRequests").is_none());
let mut requests = crate::types::mrtr::InputRequests::new();
requests.insert(
"roots".to_string(),
crate::types::mrtr::InputRequest::ListRoots,
);
store_impl
.record_input_requests(&task.task_id, "owner-a", requests)
.await
.expect("records");
let (response, claim) = dispatch
.route_tasks_get(id(), ¶ms, "owner-a", Some(Era::V2))
.await;
assert_eq!(claim, DispatchEnvelopeClaim::TASKS_INPUT_REQUIRED);
assert_eq!(
claim.owner,
crate::server::core::ReservedFieldOwner::TasksDispatch
);
assert_eq!(
claim.disposition.as_wire_str(),
"complete",
"the REQUEST completed; it is the TASK that is waiting"
);
let value = result_of(&response);
assert!(
value.get("inputRequests").is_some(),
"inputRequests must be TOP-LEVEL: {value}"
);
assert!(value.get("task").is_none(), "v2 must not wrap: {value}");
}
#[test]
fn the_router_get_value_is_projected_on_v2() {
let nested = serde_json::json!({
"task": {
"taskId": "r-1",
"status": "completed",
"ttl": 1000,
"createdAt": "2026-07-28T00:00:00Z",
"lastUpdatedAt": "2026-07-28T00:00:01Z"
},
"result": { "content": [] }
});
let projected = v2_project_router_task(nested);
assert_eq!(projected.get("taskId").and_then(Value::as_str), Some("r-1"));
assert!(projected.get("task").is_none());
assert_eq!(projected.get("ttlMs").and_then(Value::as_u64), Some(1000));
assert!(projected.get("result").is_some(), "{projected}");
let opaque = serde_json::json!({ "something": "else" });
assert_eq!(
v2_project_router_task(opaque.clone()),
opaque,
"an unparseable router value passes through rather than half-projecting"
);
}
#[tokio::test]
async fn a_detail_less_backend_degrades_rather_than_fabricating() {
let mut task = Task::new("t-1", TaskStatus::InputRequired)
.with_timestamps("2026-07-28T00:00:00Z", "2026-07-28T00:00:01Z");
task.ttl = None;
let value = v2_detailed_task_value(&task, None);
assert_eq!(
value.get("status").and_then(Value::as_str),
Some("input_required")
);
assert!(
value.get("inputRequests").is_none(),
"an empty inputRequests would be a schema-valid lie: {value}"
);
assert!(
value.get("ttlMs").is_some_and(Value::is_null),
"the five required fields survive the degradation: {value}"
);
}
}
#[cfg(any(feature = "fuzzing", test))]
#[cfg_attr(not(feature = "fuzzing"), allow(unreachable_pub, dead_code))]
pub mod fuzz_support {
use super::{TaskDispatch, TaskInputSnapshot};
use crate::types::mrtr::{InputRequest, InputRequests, InputResponses};
use crate::types::tasks::TaskStatus;
use serde_json::Value;
pub const VERDICT_ACCEPTED: u8 = 0;
pub const VERDICT_MALFORMED: u8 = 1;
pub const VERDICT_BOUNDED: u8 = 2;
pub const VERDICT_REFUSED: u8 = 3;
pub const RECORDED_ROOTS_KEY: &str = "roots";
pub const RECORDED_ELICITATION_KEY: &str = "form";
pub const RECORDED_SAMPLING_KEY: &str = "sample";
pub const MAX_ENTRIES: usize = crate::types::mrtr::MAX_INPUT_RESPONSES;
pub const MAX_ENTRY_BYTES: usize = crate::types::mrtr::MAX_INPUT_RESPONSE_BYTES;
pub const MAX_TOTAL_BYTES: usize = crate::types::mrtr::MAX_INPUT_RESPONSES_TOTAL_BYTES;
pub const MAX_DEPTH: usize = crate::types::mrtr::MAX_INPUT_RESPONSE_DEPTH;
#[derive(Debug)]
pub struct UpdateVerdict {
pub verdict: u8,
pub accepted: Vec<String>,
}
impl UpdateVerdict {
const fn plain(verdict: u8) -> Self {
Self {
verdict,
accepted: Vec::new(),
}
}
}
#[must_use]
pub fn synthetic_snapshot() -> TaskInputSnapshot {
let mut input_requests = InputRequests::new();
input_requests.insert(RECORDED_ROOTS_KEY.to_string(), InputRequest::ListRoots);
input_requests.insert(
RECORDED_ELICITATION_KEY.to_string(),
InputRequest::Elicitation(Box::new(
crate::types::elicitation::ElicitRequestParams::Form {
message: "which city?".to_string(),
requested_schema: serde_json::json!({ "type": "object" }),
},
)),
);
input_requests.insert(
RECORDED_SAMPLING_KEY.to_string(),
InputRequest::Sampling(Box::new(
serde_json::from_value(serde_json::json!({ "messages": [] }))
.expect("a minimal CreateMessageParams parses"),
)),
);
TaskInputSnapshot {
input_requests,
input_responses: InputResponses::new(),
status: TaskStatus::InputRequired,
}
}
#[must_use]
pub fn judge_update_params(input: &[u8]) -> UpdateVerdict {
let Ok(params) = serde_json::from_slice::<Value>(input) else {
return UpdateVerdict::plain(VERDICT_MALFORMED);
};
let Ok(update) = TaskDispatch::parse_tasks_update_params(¶ms) else {
return UpdateVerdict::plain(VERDICT_MALFORMED);
};
if crate::types::mrtr::check_input_responses_map_bounds(update.input_responses).is_err() {
return UpdateVerdict::plain(VERDICT_BOUNDED);
}
let snapshot = synthetic_snapshot();
match TaskDispatch::decode_inputs_against_record(update.input_responses, &snapshot) {
Ok(typed) => UpdateVerdict {
verdict: VERDICT_ACCEPTED,
accepted: typed.keys().cloned().collect(),
},
Err(_) => UpdateVerdict::plain(VERDICT_REFUSED),
}
}
}
#[cfg(test)]
mod update_delivery_tests {
use super::fuzz_support::{
judge_update_params, synthetic_snapshot, VERDICT_ACCEPTED, VERDICT_BOUNDED,
VERDICT_MALFORMED, VERDICT_REFUSED,
};
use super::*;
use crate::types::mrtr::{
MAX_CANONICAL_DEPTH, MAX_INPUT_RESPONSES, MAX_INPUT_RESPONSES_TOTAL_BYTES,
MAX_INPUT_RESPONSE_BYTES, MAX_INPUT_RESPONSE_DEPTH,
};
use proptest::prelude::*;
use serde_json::Map;
const STRATEGY_RECURSION: u32 = 4;
const _: () = assert!(
STRATEGY_RECURSION as usize + 1 < MAX_INPUT_RESPONSE_DEPTH,
"the inputResponses generator must stay strictly inside MAX_INPUT_RESPONSE_DEPTH, \
or the bounded-map property fails for a reason unrelated to the code under test"
);
const _: () = assert!(
MAX_INPUT_RESPONSE_DEPTH < MAX_CANONICAL_DEPTH,
"MAX_INPUT_RESPONSE_DEPTH must stay strictly below MAX_CANONICAL_DEPTH"
);
fn arb_response_value() -> impl Strategy<Value = Value> {
let leaf = prop_oneof![
Just(Value::Null),
any::<bool>().prop_map(Value::Bool),
any::<i32>().prop_map(|n| serde_json::json!(n)),
"[ -~]{0,12}".prop_map(Value::String),
];
leaf.prop_recursive(STRATEGY_RECURSION, 24, 3, |inner| {
prop_oneof![
prop::collection::vec(inner.clone(), 0..3).prop_map(Value::Array),
prop::collection::btree_map("[a-z]{1,6}", inner, 0..3)
.prop_map(|map| Value::Object(map.into_iter().collect())),
]
})
}
fn arb_input_responses(max_entries: usize) -> impl Strategy<Value = Map<String, Value>> {
prop::collection::btree_map(
prop_oneof![
Just("roots".to_string()),
Just("form".to_string()),
Just("sample".to_string()),
"[a-z]{1,6}",
],
arb_response_value(),
0..=max_entries,
)
.prop_map(|map| map.into_iter().collect())
}
#[test]
fn the_generator_cannot_cross_a_size_or_depth_bound() {
proptest!(|(entries in arb_input_responses(MAX_INPUT_RESPONSES))| {
let mut total = 0usize;
for (key, value) in &entries {
let bytes = serde_json::to_string(value).map_or(usize::MAX, |s| s.len());
prop_assert!(bytes <= MAX_INPUT_RESPONSE_BYTES, "{key} is too large to be generatable");
total += bytes;
}
prop_assert!(total <= MAX_INPUT_RESPONSES_TOTAL_BYTES);
});
}
proptest! {
#[test]
fn a_bounded_map_is_never_refused_by_the_bounds(
entries in arb_input_responses(MAX_INPUT_RESPONSES)
) {
prop_assert!(check_input_responses_map_bounds(&entries).is_ok());
}
#[test]
fn an_over_count_map_is_always_refused(
entries in arb_input_responses(MAX_INPUT_RESPONSES)
) {
let mut entries = entries;
for i in 0..=MAX_INPUT_RESPONSES {
entries.insert(format!("PAD-{i:04}"), Value::Null);
}
prop_assert!(entries.len() > MAX_INPUT_RESPONSES);
prop_assert!(check_input_responses_map_bounds(&entries).is_err());
}
#[test]
fn the_decode_accepts_only_recorded_keys_and_never_panics(
entries in arb_input_responses(MAX_INPUT_RESPONSES)
) {
let snapshot = synthetic_snapshot();
if let Ok(typed) = TaskDispatch::decode_inputs_against_record(&entries, &snapshot) {
for key in typed.keys() {
prop_assert!(
snapshot.input_requests.contains_key(key),
"accepted `{key}`, which the record never held"
);
}
}
}
#[test]
fn parsing_params_never_panics(
task_id in prop_oneof![
Just(Value::Null),
any::<i32>().prop_map(|n| serde_json::json!(n)),
"[ -~]{0,16}".prop_map(Value::String),
],
entries in arb_input_responses(4),
) {
let params = serde_json::json!({
"taskId": task_id.clone(),
"inputResponses": Value::Object(entries),
});
match TaskDispatch::parse_tasks_update_params(¶ms) {
Ok(update) => {
prop_assert_eq!(Some(update.task_id.as_str()), task_id.as_str());
prop_assert!(params["inputResponses"].is_object());
},
Err(message) => prop_assert!(!message.is_empty()),
}
}
}
#[test]
fn ignore_and_refuse_are_different_answers() {
let snapshot = synthetic_snapshot();
let mut ignored = Map::new();
ignored.insert(
"never-issued".to_string(),
serde_json::json!({ "nothing": true }),
);
let typed = TaskDispatch::decode_inputs_against_record(&ignored, &snapshot)
.expect("an unrecorded key is IGNORED, never an error");
assert!(
typed.is_empty(),
"and it contributes nothing to the delivery"
);
let mut refused = Map::new();
refused.insert(
"form".to_string(),
serde_json::json!({ "content": { "type": "text", "text": "x" }, "model": "m" }),
);
let error = TaskDispatch::decode_inputs_against_record(&refused, &snapshot)
.expect_err("a recorded key's value must decode as the RECORDED kind");
let rendered = error.to_string();
assert!(
rendered.contains("form"),
"names the record's key: {rendered}"
);
for from_the_value in ["model", "content", "text"] {
assert!(
!rendered.contains(from_the_value),
"the refusal must never render the value; it leaked `{from_the_value}`: {rendered}"
);
}
}
#[test]
fn the_fuzz_seam_answers_every_verdict() {
let body = |responses: Value| {
serde_json::to_vec(&serde_json::json!({
"taskId": "t-1",
"inputResponses": responses,
}))
.expect("the fixture serializes")
};
assert_eq!(
judge_update_params(b"not json at all").verdict,
VERDICT_MALFORMED
);
assert_eq!(
judge_update_params(b"{\"inputResponses\":{}}").verdict,
VERDICT_MALFORMED,
"a params object with no string taskId is malformed"
);
assert_eq!(
judge_update_params(&body(serde_json::json!({ "roots": { "roots": [] } }))).verdict,
VERDICT_ACCEPTED
);
assert_eq!(
judge_update_params(&body(serde_json::json!({
"form": { "content": { "type": "text", "text": "x" }, "model": "m" }
})))
.verdict,
VERDICT_REFUSED,
"the D-113-O shape under an elicitation key"
);
let over_count: Map<String, Value> = (0..=MAX_INPUT_RESPONSES)
.map(|i| (format!("pad-{i:04}"), Value::Null))
.collect();
assert_eq!(
judge_update_params(&body(Value::Object(over_count))).verdict,
VERDICT_BOUNDED
);
let mixed = judge_update_params(&body(serde_json::json!({
"roots": { "roots": [] },
"never-issued": { "anything": true },
})));
assert_eq!(mixed.verdict, VERDICT_ACCEPTED);
assert_eq!(mixed.accepted, vec!["roots".to_string()]);
}
#[test]
fn the_update_ack_carries_no_fields() {
let response = update_ack(RequestId::from(1i64));
let crate::types::jsonrpc::ResponsePayload::Result(value) = response.payload else {
panic!("an acknowledgement is a success result");
};
assert_eq!(value, Value::Object(Map::new()));
}
}