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]
145 pub fn with_legacy_delivery(mut self, method: &crate::Method) -> Self {
146 if method.delivers_priced_work()
147 && matches!(self.result_type, ResultType::Absent)
148 && !self.is_error
149 {
150 self.result_type = ResultType::Complete;
151 }
152 self
153 }
154
155 #[must_use]
157 pub const fn unrecognized() -> Self {
158 Self {
159 result_type: ResultType::Absent,
160 is_error: false,
161 task: None,
162 }
163 }
164}
165
166#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct TaskPeek {
169 pub task_id: Option<String>,
173 pub status: TaskStatus,
175}
176
177#[must_use]
183pub fn request(body: &Value) -> RequestPeek {
184 let Some(params) = body.get("params") else {
185 return RequestPeek::default();
186 };
187 RequestPeek {
188 is_continuation: params.get("inputResponses").is_some_and(|v| !v.is_null())
189 || params.get("requestState").is_some_and(|v| !v.is_null()),
190 }
191}
192
193#[must_use]
195pub fn response(body: &Value) -> ResponsePeek {
196 if body.get("error").is_some_and(|v| !v.is_null()) {
197 return ResponsePeek {
198 result_type: ResultType::Absent,
199 is_error: true,
200 task: None,
201 };
202 }
203
204 let Some(result) = body.get("result") else {
205 return ResponsePeek::unrecognized();
206 };
207
208 let result_type = match result.get("resultType").and_then(Value::as_str) {
209 Some("complete") => ResultType::Complete,
210 Some("input_required") => ResultType::InputRequired,
211 Some("task") => ResultType::Task,
212 Some(other) => ResultType::Unknown(other.to_owned()),
213 None => ResultType::Absent,
214 };
215
216 ResponsePeek {
217 result_type,
218 is_error: false,
219 task: peek_task(result),
220 }
221}
222
223fn peek_task(result: &Value) -> Option<TaskPeek> {
230 let status = result.get("status").and_then(Value::as_str)?;
231 Some(TaskPeek {
232 task_id: result
233 .get("taskId")
234 .and_then(Value::as_str)
235 .map(str::to_owned),
236 status: TaskStatus::parse(status),
237 })
238}
239
240#[cfg(test)]
241mod tests {
242 use crate::{Call, Charge, Method, PriceBook, decide};
243
244 fn bare_result() -> serde_json::Value {
246 json!({"jsonrpc": "2.0", "id": 1, "result": {}})
247 }
248
249 #[test]
250 fn the_legacy_promotion_reaches_only_methods_that_deliver_priced_work() {
251 for method in ["tools/call", "resources/read", "prompts/get"] {
256 let promoted = response(&bare_result()).with_legacy_delivery(&Method::parse(method));
257 assert_eq!(
258 promoted.result_type,
259 ResultType::Complete,
260 "{method} delivers priced work and must still be promoted"
261 );
262 }
263
264 for method in [
265 "ping",
266 "initialize",
267 "notifications/initialized",
268 "logging/setLevel",
269 "resources/subscribe",
270 "completion/complete",
271 "vendor/extension",
272 "tools/list",
273 "tasks/get",
274 ] {
275 let promoted = response(&bare_result()).with_legacy_delivery(&Method::parse(method));
276 assert_eq!(
277 promoted.result_type,
278 ResultType::Absent,
279 "{method} delivers no priced work and must not be promoted"
280 );
281 }
282 }
283
284 #[test]
285 fn an_idle_legacy_client_is_billed_nothing_for_its_keepalives() {
286 let prices = PriceBook::flat(1);
289 for method in ["ping", "initialize", "logging/setLevel"] {
290 let parsed = Method::parse(method);
291 let observed = response(&bare_result()).with_legacy_delivery(&parsed);
292 let charge = decide(&Call::new(parsed, None), &observed, &prices);
293 assert!(
294 matches!(charge, Charge::Free(_)),
295 "{method} billed {charge:?} on the legacy path"
296 );
297 }
298 }
299
300 #[test]
301 fn a_legacy_tool_call_is_still_billed() {
302 let observed = response(&bare_result()).with_legacy_delivery(&Method::ToolsCall);
305 let charge = decide(
306 &Call::new(Method::ToolsCall, Some("search".to_owned())),
307 &observed,
308 &PriceBook::flat(3),
309 );
310 assert!(matches!(charge, Charge::Billable(b) if b.units == 3));
311 }
312
313 #[test]
314 fn a_legacy_error_is_never_promoted() {
315 let error = json!({"jsonrpc": "2.0", "id": 1, "error": {"code": -32603, "message": "x"}});
316 let promoted = response(&error).with_legacy_delivery(&Method::ToolsCall);
317 assert!(promoted.is_error);
318 assert_eq!(promoted.result_type, ResultType::Absent);
319 }
320
321 use super::*;
322 use serde_json::json;
323
324 #[test]
325 fn a_fresh_tool_call_is_not_a_continuation() {
326 let body = json!({
327 "jsonrpc": "2.0",
328 "id": 1,
329 "method": "tools/call",
330 "params": { "name": "get_weather", "arguments": { "location": "Seattle, WA" } }
331 });
332 assert!(!request(&body).is_continuation);
333 }
334
335 #[test]
336 fn input_responses_mark_a_continuation() {
337 let body = json!({
339 "jsonrpc": "2.0",
340 "id": 3,
341 "method": "tools/call",
342 "params": {
343 "name": "get_weather",
344 "arguments": { "location": "New York" },
345 "inputResponses": {
346 "github_login": { "action": "accept", "content": { "name": "octocat" } }
347 },
348 "requestState": "eyJsb2NhdGlvbiI6Ik5ldyBZb3JrIn0..."
349 }
350 });
351 assert!(request(&body).is_continuation);
352 }
353
354 #[test]
355 fn request_state_alone_marks_a_continuation() {
356 let body = json!({
359 "jsonrpc": "2.0",
360 "id": 2,
361 "method": "tools/call",
362 "params": { "name": "t", "requestState": "AEAD-protected blob" }
363 });
364 assert!(request(&body).is_continuation);
365 }
366
367 #[test]
368 fn we_never_look_inside_request_state() {
369 for state in [
372 json!("AEAD-protected blob"),
373 json!(""),
374 json!("////not base64"),
375 ] {
376 let body = json!({ "method": "tools/call", "params": { "requestState": state } });
377 assert!(request(&body).is_continuation);
378 }
379 }
380
381 #[test]
382 fn null_markers_do_not_count_as_present() {
383 let body = json!({
384 "method": "tools/call",
385 "params": { "name": "t", "inputResponses": null, "requestState": null }
386 });
387 assert!(!request(&body).is_continuation);
388 }
389
390 #[test]
391 fn a_body_without_params_is_not_a_continuation() {
392 assert!(!request(&json!({ "method": "tools/list" })).is_continuation);
393 assert!(!request(&json!({})).is_continuation);
394 }
395
396 #[test]
397 fn reads_the_three_result_types() {
398 let complete = json!({ "id": 2, "result": { "resultType": "complete", "content": [] } });
399 assert_eq!(response(&complete).result_type, ResultType::Complete);
400
401 let interim = json!({
402 "id": 1,
403 "result": {
404 "resultType": "input_required",
405 "inputRequests": { "github_login": { "method": "elicitation/create" } },
406 "requestState": "AEAD-protected blob"
407 }
408 });
409 assert_eq!(response(&interim).result_type, ResultType::InputRequired);
410
411 let task = json!({
412 "id": 1,
413 "result": { "resultType": "task", "taskId": "tsk_1", "status": "working", "ttlMs": 60000 }
414 });
415 assert_eq!(response(&task).result_type, ResultType::Task);
416 }
417
418 #[test]
419 fn errors_are_distinguished_from_results() {
420 let err = json!({
421 "jsonrpc": "2.0",
422 "id": 1,
423 "error": { "code": -32020, "message": "Header mismatch" }
424 });
425 let peek = response(&err);
426 assert!(peek.is_error);
427 assert_eq!(peek.result_type, ResultType::Absent);
428 }
429
430 #[test]
431 fn unknown_result_types_are_preserved_not_coerced() {
432 let body = json!({ "result": { "resultType": "some_future_shape" } });
433 assert_eq!(
434 response(&body).result_type,
435 ResultType::Unknown("some_future_shape".to_owned())
436 );
437 }
438
439 #[test]
440 fn task_polls_carry_status_and_id() {
441 let working = json!({
442 "result": { "resultType": "complete", "taskId": "tsk_abc", "status": "working" }
443 });
444 let peek = response(&working);
445 let task = peek.task.expect("task present");
446 assert_eq!(task.status, TaskStatus::Working);
447 assert_eq!(task.task_id.as_deref(), Some("tsk_abc"));
448 assert!(!task.status.is_terminal());
449 assert!(!task.status.delivered());
450 }
451
452 #[test]
453 fn terminal_statuses_are_classified_correctly() {
454 assert!(TaskStatus::Completed.is_terminal() && TaskStatus::Completed.delivered());
455 assert!(TaskStatus::Failed.is_terminal() && !TaskStatus::Failed.delivered());
457 assert!(TaskStatus::Cancelled.is_terminal() && !TaskStatus::Cancelled.delivered());
458 assert!(!TaskStatus::Working.is_terminal() && !TaskStatus::Working.delivered());
459 assert!(!TaskStatus::InputRequired.is_terminal());
460 let unknown = TaskStatus::Unknown("paused".to_owned());
461 assert!(!unknown.is_terminal() && !unknown.delivered());
462 }
463
464 #[test]
465 fn task_status_round_trips_through_display() {
466 for raw in [
467 "working",
468 "input_required",
469 "completed",
470 "failed",
471 "cancelled",
472 ] {
473 assert_eq!(TaskStatus::parse(raw).to_string(), raw);
474 }
475 }
476
477 #[test]
478 fn a_plain_result_carries_no_task() {
479 let body = json!({ "result": { "resultType": "complete", "content": [] } });
480 assert!(response(&body).task.is_none());
481 }
482
483 #[test]
484 fn a_response_with_neither_result_nor_error_is_unrecognized() {
485 assert_eq!(
486 response(&json!({ "jsonrpc": "2.0" })),
487 ResponsePeek::unrecognized()
488 );
489 }
490}