1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use std::path::Path;
4
5use super::task::{
6 REASON_CANCELED_VIA_A2A, REASON_INPUT_RECEIVED, Task, TaskMessage, TaskPart, TaskState,
7 TaskStore,
8};
9
10use crate::core::context_admission::carrier::Withheld;
11
12#[derive(Debug, Deserialize)]
13pub struct JsonRpcRequest {
14 pub jsonrpc: String,
15 pub id: Value,
16 pub method: String,
17 #[serde(default)]
18 pub params: Value,
19}
20
21#[derive(Debug, Serialize)]
22pub struct JsonRpcResponse {
23 pub jsonrpc: String,
24 pub id: Value,
25 #[serde(skip_serializing_if = "Option::is_none")]
26 pub result: Option<Value>,
27 #[serde(skip_serializing_if = "Option::is_none")]
28 pub error: Option<JsonRpcError>,
29}
30
31#[derive(Debug, Serialize)]
32pub struct JsonRpcError {
33 pub code: i32,
34 pub message: String,
35 #[serde(skip_serializing_if = "Option::is_none")]
36 pub data: Option<Value>,
37}
38
39impl JsonRpcResponse {
40 fn success(id: Value, result: Value) -> Self {
41 Self {
42 jsonrpc: "2.0".to_string(),
43 id,
44 result: Some(result),
45 error: None,
46 }
47 }
48
49 fn error(id: Value, code: i32, message: &str) -> Self {
50 Self {
51 jsonrpc: "2.0".to_string(),
52 id,
53 result: None,
54 error: Some(JsonRpcError {
55 code,
56 message: message.to_string(),
57 data: None,
58 }),
59 }
60 }
61
62 pub fn server_error(id: Value, message: &str) -> Self {
63 Self::error(id, -32603, message)
64 }
65}
66
67pub const A2A_CALLER: &str = "a2a-client";
73
74pub fn handle_a2a_jsonrpc(req: &JsonRpcRequest) -> JsonRpcResponse {
78 let path = match TaskStore::default_path() {
79 Ok(path) => path,
80 Err(error) => return storage_error(req, &error),
81 };
82 handle_a2a_jsonrpc_at_path(req, &path)
83}
84
85pub fn handle_a2a_jsonrpc_at_path(req: &JsonRpcRequest, path: &Path) -> JsonRpcResponse {
86 if req.jsonrpc != "2.0" {
87 return JsonRpcResponse::error(req.id.clone(), -32600, "invalid jsonrpc version");
88 }
89
90 match req.method.as_str() {
91 "message/send" | "tasks/send" => handle_send_message(req, path),
92 "tasks/get" => handle_get_task(req, path),
93 "tasks/cancel" => handle_cancel_task(req, path),
94 _ => JsonRpcResponse::error(
95 req.id.clone(),
96 -32601,
97 &format!("method not found: {}", req.method),
98 ),
99 }
100}
101
102fn reachable_over_a2a(task: &Task) -> bool {
107 task.from_agent == A2A_CALLER && task.authority_key_id.is_none()
108}
109
110fn task_not_found(req: &JsonRpcRequest) -> JsonRpcResponse {
111 JsonRpcResponse::error(req.id.clone(), -32602, "task not found")
112}
113
114fn handle_send_message(req: &JsonRpcRequest, path: &Path) -> JsonRpcResponse {
115 let params = &req.params;
116 let message = params.get("message");
117
118 let role = match message.and_then(|m| m.get("role")).and_then(Value::as_str) {
119 None => "user",
120 Some(role @ ("user" | "agent")) => role,
121 Some(_) => {
122 return JsonRpcResponse::error(
123 req.id.clone(),
124 -32602,
125 "message.role must be \"user\" or \"agent\"",
126 );
127 }
128 };
129 let to_agent = params
130 .get("to")
131 .and_then(Value::as_str)
132 .unwrap_or("lean-ctx");
133 let parts = extract_message_parts(params);
134 let description = parts
135 .iter()
136 .find_map(|part| match part {
137 TaskPart::Text { text } if !text.is_empty() => Some(text.clone()),
138 _ => None,
139 })
140 .unwrap_or_default();
141
142 if description.is_empty() {
143 return JsonRpcResponse::error(req.id.clone(), -32602, "message text is required");
144 }
145
146 let existing = message
149 .and_then(|m| m.get("taskId"))
150 .or_else(|| params.get("id"))
151 .and_then(Value::as_str);
152
153 let refuse =
157 |withheld: Withheld| LegacyMutationError::rejected(message_withheld(req, &withheld));
158 match TaskStore::mutate_locked(path, |store| {
159 let task_id = if let Some(id) = existing {
160 let Some(task) = store
161 .get_task_mut(id)
162 .filter(|task| reachable_over_a2a(task))
163 else {
164 return Err(LegacyMutationError::rejected(task_not_found(req)));
165 };
166 task.append_message(role, &parts).map_err(refuse)?;
167 if task.state == TaskState::InputRequired {
168 task.transition(TaskState::Working, Some(REASON_INPUT_RECEIVED))
169 .map_err(std::io::Error::other)?;
170 }
171 id.to_string()
172 } else {
173 let parts = parts
174 .iter()
175 .map(TaskPart::admitted)
176 .collect::<Result<Vec<_>, _>>()
177 .map_err(refuse)?;
178 let task = store
179 .create_admitted(A2A_CALLER, to_agent, &description)
180 .map_err(refuse)?;
181 if let Some(opening) = task.messages.first_mut() {
184 opening.role = role.to_string();
185 opening.parts = parts;
186 }
187 task.id.clone()
188 };
189 Ok(JsonRpcResponse::success(
190 req.id.clone(),
191 task_to_a2a_json(store.get_task(&task_id)),
192 ))
193 }) {
194 Ok(response) => response,
195 Err(LegacyMutationError::Rejected(response)) => *response,
196 Err(LegacyMutationError::Storage(error)) => storage_error(req, &error),
197 }
198}
199
200fn handle_get_task(req: &JsonRpcRequest, path: &Path) -> JsonRpcResponse {
201 let Some(task_id) = req.params.get("id").and_then(Value::as_str) else {
202 return JsonRpcResponse::error(req.id.clone(), -32602, "id is required");
203 };
204
205 match TaskStore::read_locked(path, |store| {
206 store
207 .get_task(task_id)
208 .filter(|task| reachable_over_a2a(task))
209 .map(|task| task_to_a2a_json(Some(task)))
210 }) {
211 Ok(Some(task)) => JsonRpcResponse::success(req.id.clone(), task),
212 Ok(None) => task_not_found(req),
213 Err(error) => storage_error(req, &error),
214 }
215}
216
217fn handle_cancel_task(req: &JsonRpcRequest, path: &Path) -> JsonRpcResponse {
218 let Some(task_id) = req.params.get("id").and_then(Value::as_str) else {
219 return JsonRpcResponse::error(req.id.clone(), -32602, "id is required");
220 };
221
222 match TaskStore::mutate_locked(path, |store| {
223 let Some(task) = store
224 .get_task_mut(task_id)
225 .filter(|task| reachable_over_a2a(task))
226 else {
227 return Err(LegacyMutationError::rejected(task_not_found(req)));
228 };
229 if let Err(error) = task.transition(TaskState::Canceled, Some(REASON_CANCELED_VIA_A2A)) {
230 return Err(LegacyMutationError::rejected(JsonRpcResponse::error(
231 req.id.clone(),
232 -32603,
233 &error,
234 )));
235 }
236 Ok(JsonRpcResponse::success(
237 req.id.clone(),
238 task_to_a2a_json(Some(task)),
239 ))
240 }) {
241 Ok(response) => response,
242 Err(LegacyMutationError::Rejected(response)) => *response,
243 Err(LegacyMutationError::Storage(error)) => storage_error(req, &error),
244 }
245}
246
247enum LegacyMutationError {
249 Storage(std::io::Error),
250 Rejected(Box<JsonRpcResponse>),
251}
252
253impl LegacyMutationError {
254 fn rejected(response: JsonRpcResponse) -> Self {
255 Self::Rejected(Box::new(response))
256 }
257}
258
259impl From<std::io::Error> for LegacyMutationError {
260 fn from(error: std::io::Error) -> Self {
261 Self::Storage(error)
262 }
263}
264
265fn storage_error(req: &JsonRpcRequest, error: &std::io::Error) -> JsonRpcResponse {
266 tracing::warn!("A2A task store error: {error}");
267 JsonRpcResponse::error(req.id.clone(), -32603, "task storage unavailable")
268}
269
270fn message_withheld(req: &JsonRpcRequest, withheld: &Withheld) -> JsonRpcResponse {
273 JsonRpcResponse::error(req.id.clone(), -32602, &withheld.message("message"))
274}
275
276fn task_to_a2a_json(task: Option<&Task>) -> Value {
281 let Some(task) = task else {
282 return Value::Null;
283 };
284 let task = task.served();
285
286 let messages: Vec<Value> = task.messages.iter().map(message_to_a2a_json).collect();
287
288 let artifacts: Vec<Value> = task.artifacts.iter().map(part_to_a2a_json).collect();
289
290 let history: Vec<Value> = task
291 .history
292 .iter()
293 .map(|h| {
294 serde_json::json!({
295 "from": h.from.to_string(),
296 "to": h.to.to_string(),
297 "timestamp": h.timestamp.to_rfc3339(),
298 "reason": h.reason,
299 })
300 })
301 .collect();
302
303 serde_json::json!({
304 "id": task.id,
305 "status": serde_json::to_value(task.status_v1()).unwrap_or_default(),
306 "messages": messages,
307 "artifacts": artifacts,
308 "history": history,
309 "metadata": task.metadata,
310 "tenant_id": task.tenant_id,
311 "project_id": task.project_id,
312 "action": task.action,
313 "idempotency_key": task.idempotency_key,
314 "artifact_refs": task.artifact_refs,
315 })
316}
317
318fn message_to_a2a_json(m: &TaskMessage) -> Value {
319 let parts: Vec<Value> = m.parts.iter().map(part_to_a2a_json).collect();
320 serde_json::json!({
321 "role": m.role,
322 "parts": parts,
323 "timestamp": m.timestamp.to_rfc3339(),
324 })
325}
326
327fn part_to_a2a_json(p: &TaskPart) -> Value {
328 match p {
329 TaskPart::Text { text } => serde_json::json!({"type": "text", "text": text}),
330 TaskPart::Data { mime_type, data } => {
331 serde_json::json!({"type": "data", "mimeType": mime_type, "data": data})
332 }
333 TaskPart::File {
334 name,
335 mime_type,
336 data,
337 uri,
338 } => serde_json::json!({
339 "type": "file",
340 "file": {
341 "name": name,
342 "mimeType": mime_type,
343 "bytes": data,
344 "uri": uri,
345 }
346 }),
347 }
348}
349
350fn extract_message_parts(params: &Value) -> Vec<TaskPart> {
351 params
352 .get("message")
353 .and_then(|m| m.get("parts"))
354 .and_then(|p| p.as_array())
355 .map(|parts| {
356 parts
357 .iter()
358 .filter_map(|p| {
359 let ptype = p.get("kind").or_else(|| p.get("type"))?.as_str()?;
361 match ptype {
362 "text" => Some(TaskPart::Text {
363 text: p.get("text")?.as_str()?.to_string(),
364 }),
365 "data" => Some(TaskPart::Data {
366 mime_type: p
367 .get("mimeType")
368 .and_then(Value::as_str)
369 .unwrap_or("application/octet-stream")
370 .to_string(),
371 data: p.get("data")?.as_str()?.to_string(),
372 }),
373 _ => None,
374 }
375 })
376 .collect()
377 })
378 .unwrap_or_default()
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384
385 fn make_request(method: &str, params: Value) -> JsonRpcRequest {
386 JsonRpcRequest {
387 jsonrpc: "2.0".to_string(),
388 id: Value::Number(1.into()),
389 method: method.to_string(),
390 params,
391 }
392 }
393
394 fn send_at(path: &Path, method: &str, message: Value) -> JsonRpcResponse {
395 let mut params = serde_json::Map::new();
396 params.insert("message".to_string(), message);
397 handle_a2a_jsonrpc_at_path(&make_request(method, Value::Object(params)), path)
398 }
399
400 fn task_id(resp: &JsonRpcResponse) -> String {
401 resp.result.as_ref().expect("result")["id"]
402 .as_str()
403 .expect("task id")
404 .to_string()
405 }
406
407 fn stored_tasks(path: &Path) -> Vec<Task> {
408 TaskStore::read_locked(path, |store| store.tasks.clone()).unwrap()
409 }
410
411 #[test]
412 fn rejects_unknown_method() {
413 let req = make_request("tasks/unknown", serde_json::json!({}));
414 let resp = handle_a2a_jsonrpc(&req);
415 assert!(resp.error.is_some());
416 assert_eq!(resp.error.unwrap().code, -32601);
417 }
418
419 #[test]
420 fn rejects_missing_message_text() {
421 let req = make_request(
422 "tasks/send",
423 serde_json::json!({
424 "message": { "role": "user", "parts": [] }
425 }),
426 );
427 let resp = handle_a2a_jsonrpc(&req);
428 assert!(resp.error.is_some());
429 }
430
431 #[test]
432 fn send_creates_task() {
433 let dir = tempfile::tempdir().unwrap();
434 let path = dir.path().join("tasks.json");
435 let req = make_request(
436 "tasks/send",
437 serde_json::json!({
438 "to": "lean-ctx",
439 "message": {
440 "role": "user",
441 "parts": [{"type": "text", "text": "Fix the auth bug"}]
442 }
443 }),
444 );
445 let resp = handle_a2a_jsonrpc_at_path(&req, &path);
446 assert!(resp.result.is_some());
447 let result = resp.result.unwrap();
448 assert!(result.get("id").is_some());
449 assert_eq!(
450 result.get("status").unwrap().get("state").unwrap().as_str(),
451 Some("created")
452 );
453 }
454
455 #[test]
456 fn get_nonexistent_task_returns_error() {
457 let dir = tempfile::tempdir().unwrap();
458 let req = make_request(
459 "tasks/get",
460 serde_json::json!({"id": "nonexistent-task-id"}),
461 );
462 let resp = handle_a2a_jsonrpc_at_path(&req, &dir.path().join("tasks.json"));
463 assert!(resp.error.is_some());
464 }
465
466 #[test]
467 fn scoped_send_get_cancel_round_trip() {
468 let dir = tempfile::tempdir().unwrap();
469 let path = dir.path().join("tasks.json");
470 let send = make_request(
471 "tasks/send",
472 serde_json::json!({
473 "to": "server-a",
474 "message": {
475 "role": "agent",
476 "parts": [{"type": "text", "text": "ship it"}]
477 }
478 }),
479 );
480 let task_id = task_id(&handle_a2a_jsonrpc_at_path(&send, &path));
481
482 let get = make_request("tasks/get", serde_json::json!({"id": task_id.clone()}));
483 let fetched = handle_a2a_jsonrpc_at_path(&get, &path);
484 assert_eq!(
485 fetched.result.unwrap()["status"]["state"],
486 Value::String("created".to_string())
487 );
488
489 let cancel = make_request("tasks/cancel", serde_json::json!({"id": task_id}));
490 let canceled = handle_a2a_jsonrpc_at_path(&cancel, &path);
491 assert_eq!(
492 canceled.result.unwrap()["status"]["state"],
493 Value::String("canceled".to_string())
494 );
495 }
496
497 #[test]
499 fn role_never_becomes_the_originating_agent() {
500 let dir = tempfile::tempdir().unwrap();
501 let path = dir.path().join("tasks.json");
502 let resp = send_at(
503 &path,
504 "tasks/send",
505 serde_json::json!({"role": "agent", "parts": [{"type": "text", "text": "x"}]}),
506 );
507 let id = task_id(&resp);
508 let tasks = stored_tasks(&path);
509 let task = tasks
510 .iter()
511 .find(|task| task.id == id)
512 .expect("stored task");
513 assert_eq!(task.from_agent, A2A_CALLER);
514 assert_eq!(task.messages.len(), 1);
515 assert_eq!(task.messages[0].role, "agent");
516 }
517
518 #[test]
519 fn rejects_role_outside_the_a2a_enum() {
520 let dir = tempfile::tempdir().unwrap();
521 let path = dir.path().join("tasks.json");
522 let resp = send_at(
523 &path,
524 "message/send",
525 serde_json::json!({"role": "cursor-agent-1", "parts": [{"kind": "text", "text": "x"}]}),
526 );
527 assert_eq!(resp.error.expect("error").code, -32602);
528 assert!(!path.exists() || stored_tasks(&path).is_empty());
529 }
530
531 #[test]
532 fn message_send_accepts_kind_parts_and_task_id_follow_ups() {
533 let dir = tempfile::tempdir().unwrap();
534 let path = dir.path().join("tasks.json");
535 let first = send_at(
536 &path,
537 "message/send",
538 serde_json::json!({"role": "user", "parts": [{"kind": "text", "text": "review"}]}),
539 );
540 let id = task_id(&first);
541 let follow_up = send_at(
542 &path,
543 "message/send",
544 serde_json::json!({
545 "role": "user",
546 "taskId": id,
547 "parts": [{"kind": "text", "text": "also the tests"}]
548 }),
549 );
550 assert_eq!(task_id(&follow_up), id);
551 let tasks = stored_tasks(&path);
552 assert_eq!(tasks.len(), 1);
553 assert_eq!(tasks[0].messages.len(), 2);
554 }
555
556 #[test]
559 fn local_tasks_are_not_reachable_through_a2a_mutations() {
560 let dir = tempfile::tempdir().unwrap();
561 let path = dir.path().join("tasks.json");
562 let id = TaskStore::mutate_locked(&path, |store| {
563 Ok::<_, std::io::Error>(store.create_task("cursor-agent-1", "codex-agent-2", "local"))
564 })
565 .unwrap();
566 let before = std::fs::read(&path).unwrap();
567
568 let get = make_request("tasks/get", serde_json::json!({"id": id}));
569 assert_eq!(
570 handle_a2a_jsonrpc_at_path(&get, &path)
571 .error
572 .expect("error")
573 .code,
574 -32602
575 );
576 let cancel = make_request("tasks/cancel", serde_json::json!({"id": id}));
577 assert_eq!(
578 handle_a2a_jsonrpc_at_path(&cancel, &path)
579 .error
580 .expect("error")
581 .code,
582 -32602
583 );
584 let append = send_at(
585 &path,
586 "message/send",
587 serde_json::json!({
588 "role": "user",
589 "taskId": id,
590 "parts": [{"kind": "text", "text": "hijack"}]
591 }),
592 );
593 assert_eq!(append.error.expect("error").code, -32602);
594
595 assert_eq!(std::fs::read(&path).unwrap(), before, "denials never save");
596 let task = stored_tasks(&path).remove(0);
597 assert_eq!(task.state, TaskState::Created);
598 assert_eq!(task.messages.len(), 1, "no A2A message was appended");
599 }
600
601 #[test]
602 fn legacy_route_cannot_read_or_mutate_signed_remote_tasks() {
603 let dir = tempfile::tempdir().unwrap();
604 let path = dir.path().join("tasks.json");
605 let task_id = TaskStore::mutate_locked(&path, |store| {
608 let id = store.create_task(A2A_CALLER, "server-a", "signed work");
609 store.get_task_mut(&id).unwrap().authority_key_id = Some("key-a".to_string());
610 Ok::<_, std::io::Error>(id)
611 })
612 .unwrap();
613
614 let before_get = std::fs::read(&path).unwrap();
615 let get = make_request("tasks/get", serde_json::json!({"id": task_id}));
616 let unknown = make_request("tasks/get", serde_json::json!({"id": "unknown"}));
617 let denied = handle_a2a_jsonrpc_at_path(&get, &path);
618 let missing = handle_a2a_jsonrpc_at_path(&unknown, &path);
619 assert!(denied.result.is_none());
620 assert_eq!(
621 serde_json::to_value(&denied).unwrap(),
622 serde_json::to_value(&missing).unwrap()
623 );
624 assert_eq!(std::fs::read(&path).unwrap(), before_get);
625
626 let append = make_request(
627 "tasks/send",
628 serde_json::json!({
629 "id": task_id,
630 "message": {
631 "role": "agent",
632 "parts": [{"type": "text", "text": "tamper"}]
633 }
634 }),
635 );
636 let denied_append = handle_a2a_jsonrpc_at_path(&append, &path);
637 let mut missing_params = append.params.clone();
638 missing_params["id"] = serde_json::json!("unknown");
639 let missing_append =
640 handle_a2a_jsonrpc_at_path(&make_request("tasks/send", missing_params), &path);
641 assert_eq!(
642 serde_json::to_value(&denied_append).unwrap(),
643 serde_json::to_value(&missing_append).unwrap()
644 );
645 assert_eq!(denied_append.error.unwrap().code, -32602);
646
647 let cancel = make_request("tasks/cancel", serde_json::json!({"id": task_id}));
648 assert_eq!(std::fs::read(&path).unwrap(), before_get);
649 let denied_cancel = handle_a2a_jsonrpc_at_path(&cancel, &path);
650 let missing_cancel = handle_a2a_jsonrpc_at_path(
651 &make_request("tasks/cancel", serde_json::json!({"id": "unknown"})),
652 &path,
653 );
654 assert_eq!(
655 serde_json::to_value(&denied_cancel).unwrap(),
656 serde_json::to_value(&missing_cancel).unwrap()
657 );
658 assert_eq!(denied_cancel.error.unwrap().code, -32602);
659 assert_eq!(std::fs::read(&path).unwrap(), before_get);
660 TaskStore::read_locked(&path, |store| {
661 let task = store.get_task(&task_id).unwrap();
662 assert_eq!(task.state, TaskState::Created);
663 assert_eq!(task.messages.len(), 1);
664 })
665 .unwrap();
666 }
667
668 #[test]
669 fn a2a_caller_can_cancel_its_own_task() {
670 let dir = tempfile::tempdir().unwrap();
671 let path = dir.path().join("tasks.json");
672 let resp = send_at(
673 &path,
674 "message/send",
675 serde_json::json!({"parts": [{"kind": "text", "text": "mine"}]}),
676 );
677 let id = task_id(&resp);
678 let cancel = make_request("tasks/cancel", serde_json::json!({"id": id}));
679 assert_eq!(
680 handle_a2a_jsonrpc_at_path(&cancel, &path)
681 .result
682 .expect("result")["status"]["state"]
683 .as_str(),
684 Some("canceled")
685 );
686 }
687
688 #[test]
689 fn corrupt_store_returns_server_error_without_overwrite() {
690 let dir = tempfile::tempdir().unwrap();
691 let path = dir.path().join("tasks.json");
692 std::fs::write(&path, b"broken").unwrap();
693 let req = make_request("tasks/get", serde_json::json!({"id": "task-1"}));
694
695 let response = handle_a2a_jsonrpc_at_path(&req, &path);
696
697 assert_eq!(response.error.unwrap().code, -32603);
698 assert_eq!(std::fs::read(path).unwrap(), b"broken");
699 }
700}