1use serde_json::Value;
24use std::fmt;
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum ResultType {
29 Complete,
31 InputRequired,
33 Task,
35 Unknown(String),
38 Absent,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum TaskStatus {
46 Working,
48 InputRequired,
50 Completed,
52 Failed,
54 Cancelled,
56 Unknown(String),
58}
59
60impl TaskStatus {
61 #[must_use]
63 pub fn parse(raw: &str) -> Self {
64 match raw {
65 "working" => Self::Working,
66 "input_required" => Self::InputRequired,
67 "completed" => Self::Completed,
68 "failed" => Self::Failed,
69 "cancelled" => Self::Cancelled,
70 other => Self::Unknown(other.to_owned()),
71 }
72 }
73
74 #[must_use]
78 pub const fn is_terminal(&self) -> bool {
79 matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
80 }
81
82 #[must_use]
87 pub const fn delivered(&self) -> bool {
88 matches!(self, Self::Completed)
89 }
90}
91
92impl fmt::Display for TaskStatus {
93 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94 f.write_str(match self {
95 Self::Working => "working",
96 Self::InputRequired => "input_required",
97 Self::Completed => "completed",
98 Self::Failed => "failed",
99 Self::Cancelled => "cancelled",
100 Self::Unknown(s) => s,
101 })
102 }
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Default)]
107pub struct RequestPeek {
108 pub is_continuation: bool,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct ResponsePeek {
116 pub result_type: ResultType,
118 pub is_error: bool,
120 pub task: Option<TaskPeek>,
122}
123
124impl ResponsePeek {
125 #[must_use]
139 pub fn with_legacy_delivery(mut self) -> Self {
140 if matches!(self.result_type, ResultType::Absent) && !self.is_error {
141 self.result_type = ResultType::Complete;
142 }
143 self
144 }
145
146 #[must_use]
148 pub const fn unrecognized() -> Self {
149 Self {
150 result_type: ResultType::Absent,
151 is_error: false,
152 task: None,
153 }
154 }
155}
156
157#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct TaskPeek {
160 pub task_id: Option<String>,
164 pub status: TaskStatus,
166}
167
168#[must_use]
174pub fn request(body: &Value) -> RequestPeek {
175 let Some(params) = body.get("params") else {
176 return RequestPeek::default();
177 };
178 RequestPeek {
179 is_continuation: params.get("inputResponses").is_some_and(|v| !v.is_null())
180 || params.get("requestState").is_some_and(|v| !v.is_null()),
181 }
182}
183
184#[must_use]
186pub fn response(body: &Value) -> ResponsePeek {
187 if body.get("error").is_some_and(|v| !v.is_null()) {
188 return ResponsePeek {
189 result_type: ResultType::Absent,
190 is_error: true,
191 task: None,
192 };
193 }
194
195 let Some(result) = body.get("result") else {
196 return ResponsePeek::unrecognized();
197 };
198
199 let result_type = match result.get("resultType").and_then(Value::as_str) {
200 Some("complete") => ResultType::Complete,
201 Some("input_required") => ResultType::InputRequired,
202 Some("task") => ResultType::Task,
203 Some(other) => ResultType::Unknown(other.to_owned()),
204 None => ResultType::Absent,
205 };
206
207 ResponsePeek {
208 result_type,
209 is_error: false,
210 task: peek_task(result),
211 }
212}
213
214fn peek_task(result: &Value) -> Option<TaskPeek> {
221 let status = result.get("status").and_then(Value::as_str)?;
222 Some(TaskPeek {
223 task_id: result
224 .get("taskId")
225 .and_then(Value::as_str)
226 .map(str::to_owned),
227 status: TaskStatus::parse(status),
228 })
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234 use serde_json::json;
235
236 #[test]
237 fn a_fresh_tool_call_is_not_a_continuation() {
238 let body = json!({
239 "jsonrpc": "2.0",
240 "id": 1,
241 "method": "tools/call",
242 "params": { "name": "get_weather", "arguments": { "location": "Seattle, WA" } }
243 });
244 assert!(!request(&body).is_continuation);
245 }
246
247 #[test]
248 fn input_responses_mark_a_continuation() {
249 let body = json!({
251 "jsonrpc": "2.0",
252 "id": 3,
253 "method": "tools/call",
254 "params": {
255 "name": "get_weather",
256 "arguments": { "location": "New York" },
257 "inputResponses": {
258 "github_login": { "action": "accept", "content": { "name": "octocat" } }
259 },
260 "requestState": "eyJsb2NhdGlvbiI6Ik5ldyBZb3JrIn0..."
261 }
262 });
263 assert!(request(&body).is_continuation);
264 }
265
266 #[test]
267 fn request_state_alone_marks_a_continuation() {
268 let body = json!({
271 "jsonrpc": "2.0",
272 "id": 2,
273 "method": "tools/call",
274 "params": { "name": "t", "requestState": "AEAD-protected blob" }
275 });
276 assert!(request(&body).is_continuation);
277 }
278
279 #[test]
280 fn we_never_look_inside_request_state() {
281 for state in [
284 json!("AEAD-protected blob"),
285 json!(""),
286 json!("////not base64"),
287 ] {
288 let body = json!({ "method": "tools/call", "params": { "requestState": state } });
289 assert!(request(&body).is_continuation);
290 }
291 }
292
293 #[test]
294 fn null_markers_do_not_count_as_present() {
295 let body = json!({
296 "method": "tools/call",
297 "params": { "name": "t", "inputResponses": null, "requestState": null }
298 });
299 assert!(!request(&body).is_continuation);
300 }
301
302 #[test]
303 fn a_body_without_params_is_not_a_continuation() {
304 assert!(!request(&json!({ "method": "tools/list" })).is_continuation);
305 assert!(!request(&json!({})).is_continuation);
306 }
307
308 #[test]
309 fn reads_the_three_result_types() {
310 let complete = json!({ "id": 2, "result": { "resultType": "complete", "content": [] } });
311 assert_eq!(response(&complete).result_type, ResultType::Complete);
312
313 let interim = json!({
314 "id": 1,
315 "result": {
316 "resultType": "input_required",
317 "inputRequests": { "github_login": { "method": "elicitation/create" } },
318 "requestState": "AEAD-protected blob"
319 }
320 });
321 assert_eq!(response(&interim).result_type, ResultType::InputRequired);
322
323 let task = json!({
324 "id": 1,
325 "result": { "resultType": "task", "taskId": "tsk_1", "status": "working", "ttlMs": 60000 }
326 });
327 assert_eq!(response(&task).result_type, ResultType::Task);
328 }
329
330 #[test]
331 fn errors_are_distinguished_from_results() {
332 let err = json!({
333 "jsonrpc": "2.0",
334 "id": 1,
335 "error": { "code": -32020, "message": "Header mismatch" }
336 });
337 let peek = response(&err);
338 assert!(peek.is_error);
339 assert_eq!(peek.result_type, ResultType::Absent);
340 }
341
342 #[test]
343 fn unknown_result_types_are_preserved_not_coerced() {
344 let body = json!({ "result": { "resultType": "some_future_shape" } });
345 assert_eq!(
346 response(&body).result_type,
347 ResultType::Unknown("some_future_shape".to_owned())
348 );
349 }
350
351 #[test]
352 fn task_polls_carry_status_and_id() {
353 let working = json!({
354 "result": { "resultType": "complete", "taskId": "tsk_abc", "status": "working" }
355 });
356 let peek = response(&working);
357 let task = peek.task.expect("task present");
358 assert_eq!(task.status, TaskStatus::Working);
359 assert_eq!(task.task_id.as_deref(), Some("tsk_abc"));
360 assert!(!task.status.is_terminal());
361 assert!(!task.status.delivered());
362 }
363
364 #[test]
365 fn terminal_statuses_are_classified_correctly() {
366 assert!(TaskStatus::Completed.is_terminal() && TaskStatus::Completed.delivered());
367 assert!(TaskStatus::Failed.is_terminal() && !TaskStatus::Failed.delivered());
369 assert!(TaskStatus::Cancelled.is_terminal() && !TaskStatus::Cancelled.delivered());
370 assert!(!TaskStatus::Working.is_terminal() && !TaskStatus::Working.delivered());
371 assert!(!TaskStatus::InputRequired.is_terminal());
372 let unknown = TaskStatus::Unknown("paused".to_owned());
373 assert!(!unknown.is_terminal() && !unknown.delivered());
374 }
375
376 #[test]
377 fn task_status_round_trips_through_display() {
378 for raw in [
379 "working",
380 "input_required",
381 "completed",
382 "failed",
383 "cancelled",
384 ] {
385 assert_eq!(TaskStatus::parse(raw).to_string(), raw);
386 }
387 }
388
389 #[test]
390 fn a_plain_result_carries_no_task() {
391 let body = json!({ "result": { "resultType": "complete", "content": [] } });
392 assert!(response(&body).task.is_none());
393 }
394
395 #[test]
396 fn a_response_with_neither_result_nor_error_is_unrecognized() {
397 assert_eq!(
398 response(&json!({ "jsonrpc": "2.0" })),
399 ResponsePeek::unrecognized()
400 );
401 }
402}