diff --git a/AGENTS.md b/AGENTS.md index e73cf0db7..51da2ca7e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,14 +47,14 @@ This is a **protocol schema library** (not a runtime application). There are no ### Key commands (see `package.json` scripts) -| Command | Purpose | -|---|---| -| `npm run check` | Full CI pipeline: clippy, format check, spellcheck, tests | -| `npm run generate` | Regenerate JSON schemas from Rust types + format | -| `cargo test --all-features` | Run Rust unit + doc tests | -| `cargo clippy --all-features` | Lint Rust code | -| `npm run format:check` | Verify Prettier + rustfmt formatting | -| `npm run format` | Auto-fix formatting | +| Command | Purpose | +| ----------------------------- | --------------------------------------------------------- | +| `npm run check` | Full CI pipeline: clippy, format check, spellcheck, tests | +| `npm run generate` | Regenerate JSON schemas from Rust types + format | +| `cargo test --all-features` | Run Rust unit + doc tests | +| `cargo clippy --all-features` | Lint Rust code | +| `npm run format:check` | Verify Prettier + rustfmt formatting | +| `npm run format` | Auto-fix formatting | ### Gotchas diff --git a/src/v1/ext.rs b/src/v1/ext.rs index 7831529fb..0e7dd7caa 100644 --- a/src/v1/ext.rs +++ b/src/v1/ext.rs @@ -86,3 +86,64 @@ impl ExtNotification { } } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::value::RawValue; + + fn raw(s: &str) -> Arc { + RawValue::from_string(s.to_string()).unwrap().into() + } + + #[test] + fn ext_request_new_preserves_method_verbatim() { + // Constructor accepts anything `Into>`. The method name must + // be stored exactly as given so the routing layer can dispatch it. + let req = ExtRequest::new("_vendor/custom_action", raw(r#"{"x":1}"#)); + assert_eq!(req.method.as_ref(), "_vendor/custom_action"); + + let req = ExtRequest::new(String::from("_other"), raw("null")); + assert_eq!(req.method.as_ref(), "_other"); + + let arc: Arc = Arc::from("_keep"); + let req = ExtRequest::new(arc.clone(), raw("[]")); + assert!(Arc::ptr_eq(&req.method, &arc), "Arc should be reused"); + } + + #[test] + fn ext_request_serializes_only_params_not_method() { + // `#[serde(transparent)]` + `#[serde(skip)]` on method means the JSON + // surface is exactly the params blob. Emitting the method name into + // the params payload would break custom RPC handlers. + let req = ExtRequest::new("_vendor/x", raw(r#"{"answer":42}"#)); + let serialized = serde_json::to_value(&req).unwrap(); + assert_eq!(serialized, serde_json::json!({"answer": 42})); + assert!( + !serialized.to_string().contains("_vendor/x"), + "method name must not leak into the serialized params" + ); + } + + #[test] + fn ext_notification_serializes_only_params_not_method() { + let note = ExtNotification::new("_telemetry/event", raw(r#"{"k":"v"}"#)); + let serialized = serde_json::to_value(¬e).unwrap(); + assert_eq!(serialized, serde_json::json!({"k": "v"})); + } + + #[test] + fn ext_response_serializes_transparently() { + // ExtResponse is a thin wrapper; the wire format must equal its inner + // RawValue with no envelope, otherwise extension responses would not + // be assignable to a typed result on the client side. + let resp = ExtResponse::new(raw(r#"{"ok":true}"#)); + let serialized = serde_json::to_value(&resp).unwrap(); + assert_eq!(serialized, serde_json::json!({"ok": true})); + + // The From> impl also works. + let from_impl: ExtResponse = raw("42").into(); + let serialized = serde_json::to_value(&from_impl).unwrap(); + assert_eq!(serialized, serde_json::json!(42)); + } +} diff --git a/src/v1/plan.rs b/src/v1/plan.rs index a4910c13c..253c7c9bb 100644 --- a/src/v1/plan.rs +++ b/src/v1/plan.rs @@ -145,3 +145,120 @@ pub enum PlanEntryStatus { /// The task has been successfully completed. Completed, } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn entry() -> PlanEntry { + PlanEntry::new("step", PlanEntryPriority::Medium, PlanEntryStatus::Pending) + } + + #[test] + fn plan_entry_roundtrip_omits_optional_meta() { + let value = entry(); + let serialized = serde_json::to_value(&value).unwrap(); + assert_eq!( + serialized, + json!({ + "content": "step", + "priority": "medium", + "status": "pending", + }) + ); + let parsed: PlanEntry = serde_json::from_value(serialized).unwrap(); + assert_eq!(parsed, value); + } + + #[test] + fn plan_entry_priority_and_status_use_snake_case() { + assert_eq!( + serde_json::to_value(&PlanEntryPriority::High).unwrap(), + json!("high"), + ); + assert_eq!( + serde_json::to_value(&PlanEntryPriority::Low).unwrap(), + json!("low"), + ); + assert_eq!( + serde_json::to_value(&PlanEntryStatus::InProgress).unwrap(), + json!("in_progress"), + ); + assert_eq!( + serde_json::to_value(&PlanEntryStatus::Completed).unwrap(), + json!("completed"), + ); + + let parsed: PlanEntryPriority = serde_json::from_str("\"high\"").unwrap(); + assert_eq!(parsed, PlanEntryPriority::High); + let parsed: PlanEntryStatus = serde_json::from_str("\"in_progress\"").unwrap(); + assert_eq!(parsed, PlanEntryStatus::InProgress); + } + + #[test] + fn plan_skips_malformed_entries_keeping_valid_ones() { + // Mirrors the `DefaultOnError>` pattern + // applied to `Plan::entries` so a single bad entry does not poison the + // whole plan update. + let input = json!({ + "entries": [ + {"content": "ok 1", "priority": "high", "status": "pending"}, + {"content": "missing priority", "status": "pending"}, + {"content": "wrong types", "priority": 7, "status": false}, + "not even an object", + {"content": "ok 2", "priority": "low", "status": "completed"}, + ] + }); + + let plan: Plan = serde_json::from_value(input).unwrap(); + assert_eq!(plan.entries.len(), 2); + assert_eq!(plan.entries[0].content, "ok 1"); + assert_eq!(plan.entries[0].priority, PlanEntryPriority::High); + assert_eq!(plan.entries[1].content, "ok 2"); + assert_eq!(plan.entries[1].status, PlanEntryStatus::Completed); + } + + #[test] + fn plan_collapses_outer_shape_errors_to_empty_entries() { + // `DefaultOnError` should swallow outer-shape failures so a producer + // sending the wrong type for `entries` does not break consumers. + // `entries` is required on the wire (no `#[serde(default)]`), so a + // missing key still errors; that's intentional and documented here. + let cases = [ + json!({ "entries": null }), + json!({ "entries": "oops" }), + json!({ "entries": {"k": 1} }), + json!({ "entries": 42 }), + ]; + for input in cases { + let plan: Plan = serde_json::from_value(input.clone()).unwrap_or_else(|e| { + panic!("expected Plan to deserialize from {input}: {e}"); + }); + assert!( + plan.entries.is_empty(), + "expected empty entries for {input}, got {:?}", + plan.entries + ); + } + + // Missing key is still an error - the field is required. + let err = serde_json::from_value::(json!({})).unwrap_err(); + assert!( + err.to_string().contains("entries"), + "expected missing-field error to mention `entries`, got: {err}" + ); + } + + #[test] + fn plan_preserves_meta_when_present() { + let mut meta = Meta::new(); + meta.insert("k".to_string(), json!("v")); + let value = Plan::new(vec![entry()]).meta(meta.clone()); + + let serialized = serde_json::to_value(&value).unwrap(); + assert_eq!(serialized["_meta"], json!({"k": "v"})); + let parsed: Plan = serde_json::from_value(serialized).unwrap(); + assert_eq!(parsed.meta, Some(meta)); + } +} diff --git a/src/v1/protocol_level.rs b/src/v1/protocol_level.rs index 0fbb45b55..096008aca 100644 --- a/src/v1/protocol_level.rs +++ b/src/v1/protocol_level.rs @@ -120,3 +120,76 @@ impl ProtocolLevelNotification { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::v1::RequestId; + use serde_json::json; + + #[test] + fn cancel_request_method_name_is_dollar_namespace() { + // `$/cancel_request` is the protocol-level method name that all sides + // are expected to recognize (or safely ignore). Renaming this is a + // wire-format break. + assert_eq!(CANCEL_REQUEST_METHOD_NAME, "$/cancel_request"); + assert_eq!( + PROTOCOL_LEVEL_METHOD_NAMES.cancel_request, + CANCEL_REQUEST_METHOD_NAME + ); + } + + #[test] + fn protocol_level_notification_method_returns_cancel_request_name() { + let notif = ProtocolLevelNotification::CancelRequestNotification( + CancelRequestNotification::new(RequestId::Number(7)), + ); + assert_eq!(notif.method(), CANCEL_REQUEST_METHOD_NAME); + } + + #[test] + fn cancel_request_notification_roundtrip_omits_meta_when_unset() { + let notif = CancelRequestNotification::new(RequestId::Str("req_1".to_string())); + let serialized = serde_json::to_value(¬if).unwrap(); + assert_eq!(serialized, json!({"requestId": "req_1"})); + + let parsed: CancelRequestNotification = serde_json::from_value(serialized).unwrap(); + assert_eq!(parsed, notif); + } + + #[test] + fn cancel_request_notification_preserves_meta_when_set() { + let mut meta = Meta::new(); + meta.insert("trace".to_string(), json!("abc123")); + let notif = CancelRequestNotification::new(RequestId::Number(42)).meta(meta.clone()); + + let serialized = serde_json::to_value(¬if).unwrap(); + assert_eq!( + serialized, + json!({"requestId": 42, "_meta": {"trace": "abc123"}}) + ); + + let parsed: CancelRequestNotification = serde_json::from_value(serialized).unwrap(); + assert_eq!(parsed.meta, Some(meta)); + } + + #[test] + fn cancel_request_accepts_all_request_id_shapes() { + // RequestId is untagged: null / number / string. The cancellation + // notification must round-trip any of them so cancellation works + // regardless of how the original request was issued. + let cases = [ + (RequestId::Null, json!(null)), + (RequestId::Number(0), json!(0)), + (RequestId::Number(-1), json!(-1)), + (RequestId::Str("hex-id".to_string()), json!("hex-id")), + ]; + for (id, expected_id_json) in cases { + let notif = CancelRequestNotification::new(id.clone()); + let serialized = serde_json::to_value(¬if).unwrap(); + assert_eq!(serialized["requestId"], expected_id_json, "id: {id:?}"); + let parsed: CancelRequestNotification = serde_json::from_value(serialized).unwrap(); + assert_eq!(parsed.request_id, id); + } + } +} diff --git a/src/v1/tool_call.rs b/src/v1/tool_call.rs index f096982d3..4e1077e61 100644 --- a/src/v1/tool_call.rs +++ b/src/v1/tool_call.rs @@ -672,3 +672,306 @@ impl ToolCallLocation { self } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::v1::{ContentBlock, ErrorCode, TextContent}; + use serde_json::json; + + fn full_tool_call() -> ToolCall { + ToolCall::new("tc_1", "edit file") + .kind(ToolKind::Edit) + .status(ToolCallStatus::InProgress) + .content(vec![ToolCallContent::Diff( + Diff::new("/tmp/a.rs", "new").old_text("old"), + )]) + .locations(vec![ToolCallLocation::new("/tmp/a.rs").line(7)]) + .raw_input(json!({"path": "/tmp/a.rs"})) + .raw_output(json!({"ok": true})) + } + + #[test] + fn tool_call_default_kind_and_status_are_omitted() { + let value = ToolCall::new("tc", "title"); + let serialized = serde_json::to_value(&value).unwrap(); + let obj = serialized.as_object().unwrap(); + // `Other` and `Pending` are the defaults and must be skipped to keep + // the wire format minimal. + assert!( + !obj.contains_key("kind"), + "kind should be skipped: {serialized}" + ); + assert!( + !obj.contains_key("status"), + "status should be skipped: {serialized}" + ); + assert!(!obj.contains_key("content")); + assert!(!obj.contains_key("locations")); + assert!(!obj.contains_key("rawInput")); + assert!(!obj.contains_key("rawOutput")); + assert!(!obj.contains_key("_meta")); + } + + #[test] + fn tool_kind_unknown_variants_decode_to_other() { + // ToolKind uses `#[serde(other)]` so any new variant introduced by a + // future spec revision must deserialize as Other rather than failing. + let kind: ToolKind = serde_json::from_str("\"future_kind\"").unwrap(); + assert_eq!(kind, ToolKind::Other); + assert_eq!( + serde_json::to_value(ToolKind::Other).unwrap(), + json!("other") + ); + } + + #[test] + fn tool_call_status_pending_is_default() { + assert_eq!(ToolCallStatus::default(), ToolCallStatus::Pending); + assert_eq!( + serde_json::to_value(ToolCallStatus::InProgress).unwrap(), + json!("in_progress") + ); + let parsed: ToolCallStatus = serde_json::from_str("\"completed\"").unwrap(); + assert_eq!(parsed, ToolCallStatus::Completed); + } + + #[test] + fn tool_call_roundtrip_preserves_all_fields() { + let value = full_tool_call(); + let serialized = serde_json::to_value(&value).unwrap(); + let parsed: ToolCall = serde_json::from_value(serialized).unwrap(); + assert_eq!(parsed, value); + } + + #[test] + fn tool_call_update_only_overwrites_present_fields() { + let mut call = full_tool_call(); + + let fields = ToolCallUpdateFields::new().status(ToolCallStatus::Completed); + call.update(fields); + + // Only status changes; other fields are untouched. + assert_eq!(call.status, ToolCallStatus::Completed); + assert_eq!(call.title, "edit file"); + assert_eq!(call.kind, ToolKind::Edit); + assert_eq!(call.content.len(), 1); + assert_eq!(call.locations.len(), 1); + assert_eq!(call.raw_input, Some(json!({"path": "/tmp/a.rs"}))); + } + + #[test] + fn tool_call_update_replaces_collections_not_extends() { + let mut call = full_tool_call(); + let fields = ToolCallUpdateFields::new() + .content(vec![ToolCallContent::Content(Content::new( + ContentBlock::Text(TextContent::new("only new")), + ))]) + .locations(vec![ToolCallLocation::new("/other")]); + call.update(fields); + + assert_eq!(call.content.len(), 1); + match &call.content[0] { + ToolCallContent::Content(c) => match &c.content { + ContentBlock::Text(t) => assert_eq!(t.text, "only new"), + _ => panic!("expected text content"), + }, + _ => panic!("expected Content variant after replacement"), + } + assert_eq!(call.locations.len(), 1); + assert_eq!(call.locations[0].path, std::path::PathBuf::from("/other")); + } + + #[test] + fn tool_call_try_from_update_requires_title() { + let update = ToolCallUpdate::new("tc_1", ToolCallUpdateFields::new()); + let err = ToolCall::try_from(update).unwrap_err(); + assert_eq!(err.code, ErrorCode::InvalidParams); + assert_eq!( + err.data.as_ref().and_then(|v| v.as_str()), + Some("title is required for a tool call"), + "title-required error should carry a stable, actionable message", + ); + } + + #[test] + fn tool_call_try_from_update_fills_defaults_for_missing_optionals() { + let update = ToolCallUpdate::new( + "tc_1", + ToolCallUpdateFields::new() + .title("hello") + .raw_input(json!({"x": 1})), + ); + + let call = ToolCall::try_from(update).unwrap(); + assert_eq!(call.tool_call_id, ToolCallId::new("tc_1")); + assert_eq!(call.title, "hello"); + assert_eq!(call.kind, ToolKind::default()); + assert_eq!(call.status, ToolCallStatus::default()); + assert!(call.content.is_empty()); + assert!(call.locations.is_empty()); + assert_eq!(call.raw_input, Some(json!({"x": 1}))); + assert_eq!(call.raw_output, None); + } + + #[test] + fn tool_call_to_update_preserves_every_field() { + // `From for ToolCallUpdate` is used when re-broadcasting a + // tool call as an update. Dropping any field here would silently + // erase state on the receiving side. + let call = full_tool_call(); + let update: ToolCallUpdate = call.clone().into(); + assert_eq!(update.tool_call_id, call.tool_call_id); + assert_eq!(update.fields.title.as_deref(), Some("edit file")); + assert_eq!(update.fields.kind, Some(ToolKind::Edit)); + assert_eq!(update.fields.status, Some(ToolCallStatus::InProgress)); + assert_eq!(update.fields.content.as_ref().map(Vec::len), Some(1)); + assert_eq!(update.fields.locations.as_ref().map(Vec::len), Some(1)); + assert_eq!(update.fields.raw_input, Some(json!({"path": "/tmp/a.rs"}))); + assert_eq!(update.fields.raw_output, Some(json!({"ok": true}))); + + // And the round trip rebuilds an equivalent ToolCall. + let rebuilt = ToolCall::try_from(update).unwrap(); + assert_eq!(rebuilt, call); + } + + #[test] + fn tool_call_update_flattens_fields_on_wire() { + // `#[serde(flatten)]` means fields live at the top level of the JSON + // object next to `toolCallId`, not nested under a `fields` key. + let update = ToolCallUpdate::new( + "tc_1", + ToolCallUpdateFields::new() + .status(ToolCallStatus::Completed) + .title("done"), + ); + let serialized = serde_json::to_value(&update).unwrap(); + assert_eq!(serialized["toolCallId"], json!("tc_1")); + assert_eq!(serialized["status"], json!("completed")); + assert_eq!(serialized["title"], json!("done")); + assert!( + serialized.get("fields").is_none(), + "fields should be flattened" + ); + } + + #[test] + fn tool_call_content_discriminator_is_type_snake_case() { + let diff = ToolCallContent::Diff(Diff::new("/p", "new")); + let json = serde_json::to_value(&diff).unwrap(); + assert_eq!(json["type"], json!("diff")); + + let parsed: ToolCallContent = serde_json::from_value(json!({ + "type": "content", + "content": {"type": "text", "text": "hi"} + })) + .unwrap(); + match parsed { + ToolCallContent::Content(c) => match c.content { + ContentBlock::Text(t) => assert_eq!(t.text, "hi"), + _ => panic!("expected text"), + }, + _ => panic!("expected Content variant"), + } + + let parsed: ToolCallContent = serde_json::from_value(json!({ + "type": "terminal", + "terminalId": "term_1", + })) + .unwrap(); + match parsed { + ToolCallContent::Terminal(t) => assert_eq!(t.terminal_id.0.as_ref(), "term_1"), + _ => panic!("expected Terminal variant"), + } + } + + #[test] + fn tool_call_skips_malformed_content_and_locations() { + // Mirror of the `Plan::entries` resilience: a single bad item in + // `content` or `locations` must not poison the whole tool call. + let input = json!({ + "toolCallId": "tc_1", + "title": "hi", + "content": [ + {"type": "content", "content": {"type": "text", "text": "ok"}}, + {"type": "diff", "missingPath": true}, + "not even an object", + {"type": "totally_unknown_kind"}, + {"type": "diff", "path": "/p", "newText": "n"}, + ], + "locations": [ + {"path": "/ok"}, + {"path": 42}, + "nope", + ] + }); + + let call: ToolCall = serde_json::from_value(input).unwrap(); + assert_eq!(call.content.len(), 2); + match &call.content[0] { + ToolCallContent::Content(_) => {} + _ => panic!("expected text content first"), + } + match &call.content[1] { + ToolCallContent::Diff(d) => assert_eq!(d.new_text, "n"), + _ => panic!("expected diff second"), + } + assert_eq!(call.locations.len(), 1); + assert_eq!(call.locations[0].path, std::path::PathBuf::from("/ok")); + } + + #[test] + fn tool_call_treats_outer_shape_errors_as_empty_collections() { + // `DefaultOnError` swallows outer-shape failures for content/locations. + let input = json!({ + "toolCallId": "tc_1", + "title": "hi", + "content": "oops", + "locations": {"k": 1} + }); + let call: ToolCall = serde_json::from_value(input).unwrap(); + assert!(call.content.is_empty()); + assert!(call.locations.is_empty()); + } + + #[test] + fn tool_call_update_fields_tolerate_unknown_kind_and_status() { + // Each scalar field on the update is wrapped in `DefaultOnError`, + // so a future enum variant arriving over the wire collapses to + // `None` (or `Some(Other)` where the type has an `other` catch-all) + // instead of failing the whole update. + let fields: ToolCallUpdateFields = serde_json::from_value(json!({ + "kind": "totally_new_kind", + "status": "totally_new_status", + })) + .unwrap(); + // serde(other) on ToolKind means unknown kinds decode to Other (Some). + assert_eq!(fields.kind, Some(ToolKind::Other)); + // ToolCallStatus has no `other` variant, so DefaultOnError -> None. + assert_eq!(fields.status, None); + } + + #[test] + fn diff_new_leaves_old_text_unset() { + let d = Diff::new("/p", "new"); + assert_eq!(d.old_text, None); + let serialized = serde_json::to_value(&d).unwrap(); + assert!(serialized.as_object().unwrap().get("oldText").is_none()); + } + + #[test] + fn tool_call_content_from_content_block_wraps_in_content_variant() { + let block: ContentBlock = ContentBlock::Text(TextContent::new("hi")); + let tcc: ToolCallContent = block.into(); + match tcc { + ToolCallContent::Content(_) => {} + _ => panic!("expected Content variant from blanket From impl"), + } + + let from_diff: ToolCallContent = Diff::new("/p", "n").into(); + match from_diff { + ToolCallContent::Diff(_) => {} + _ => panic!("expected Diff variant from From"), + } + } +} diff --git a/src/v2/ext.rs b/src/v2/ext.rs index 7831529fb..f424cc353 100644 --- a/src/v2/ext.rs +++ b/src/v2/ext.rs @@ -86,3 +86,55 @@ impl ExtNotification { } } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::value::RawValue; + + fn raw(s: &str) -> Arc { + RawValue::from_string(s.to_string()).unwrap().into() + } + + #[test] + fn ext_request_new_preserves_method_verbatim() { + let req = ExtRequest::new("_vendor/custom_action", raw(r#"{"x":1}"#)); + assert_eq!(req.method.as_ref(), "_vendor/custom_action"); + + let req = ExtRequest::new(String::from("_other"), raw("null")); + assert_eq!(req.method.as_ref(), "_other"); + + let arc: Arc = Arc::from("_keep"); + let req = ExtRequest::new(arc.clone(), raw("[]")); + assert!(Arc::ptr_eq(&req.method, &arc), "Arc should be reused"); + } + + #[test] + fn ext_request_serializes_only_params_not_method() { + let req = ExtRequest::new("_vendor/x", raw(r#"{"answer":42}"#)); + let serialized = serde_json::to_value(&req).unwrap(); + assert_eq!(serialized, serde_json::json!({"answer": 42})); + assert!( + !serialized.to_string().contains("_vendor/x"), + "method name must not leak into the serialized params" + ); + } + + #[test] + fn ext_notification_serializes_only_params_not_method() { + let note = ExtNotification::new("_telemetry/event", raw(r#"{"k":"v"}"#)); + let serialized = serde_json::to_value(¬e).unwrap(); + assert_eq!(serialized, serde_json::json!({"k": "v"})); + } + + #[test] + fn ext_response_serializes_transparently() { + let resp = ExtResponse::new(raw(r#"{"ok":true}"#)); + let serialized = serde_json::to_value(&resp).unwrap(); + assert_eq!(serialized, serde_json::json!({"ok": true})); + + let from_impl: ExtResponse = raw("42").into(); + let serialized = serde_json::to_value(&from_impl).unwrap(); + assert_eq!(serialized, serde_json::json!(42)); + } +} diff --git a/src/v2/plan.rs b/src/v2/plan.rs index 38a6e5973..070381ca7 100644 --- a/src/v2/plan.rs +++ b/src/v2/plan.rs @@ -146,3 +146,120 @@ pub enum PlanEntryStatus { /// The task has been successfully completed. Completed, } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn entry() -> PlanEntry { + PlanEntry::new("step", PlanEntryPriority::Medium, PlanEntryStatus::Pending) + } + + #[test] + fn plan_entry_roundtrip_omits_optional_meta() { + let value = entry(); + let serialized = serde_json::to_value(&value).unwrap(); + assert_eq!( + serialized, + json!({ + "content": "step", + "priority": "medium", + "status": "pending", + }) + ); + let parsed: PlanEntry = serde_json::from_value(serialized).unwrap(); + assert_eq!(parsed, value); + } + + #[test] + fn plan_entry_priority_and_status_use_snake_case() { + assert_eq!( + serde_json::to_value(&PlanEntryPriority::High).unwrap(), + json!("high"), + ); + assert_eq!( + serde_json::to_value(&PlanEntryPriority::Low).unwrap(), + json!("low"), + ); + assert_eq!( + serde_json::to_value(&PlanEntryStatus::InProgress).unwrap(), + json!("in_progress"), + ); + assert_eq!( + serde_json::to_value(&PlanEntryStatus::Completed).unwrap(), + json!("completed"), + ); + + let parsed: PlanEntryPriority = serde_json::from_str("\"high\"").unwrap(); + assert_eq!(parsed, PlanEntryPriority::High); + let parsed: PlanEntryStatus = serde_json::from_str("\"in_progress\"").unwrap(); + assert_eq!(parsed, PlanEntryStatus::InProgress); + } + + #[test] + fn plan_skips_malformed_entries_keeping_valid_ones() { + // Mirrors the `DefaultOnError>` pattern + // applied to `Plan::entries` so a single bad entry does not poison the + // whole plan update. + let input = json!({ + "entries": [ + {"content": "ok 1", "priority": "high", "status": "pending"}, + {"content": "missing priority", "status": "pending"}, + {"content": "wrong types", "priority": 7, "status": false}, + "not even an object", + {"content": "ok 2", "priority": "low", "status": "completed"}, + ] + }); + + let plan: Plan = serde_json::from_value(input).unwrap(); + assert_eq!(plan.entries.len(), 2); + assert_eq!(plan.entries[0].content, "ok 1"); + assert_eq!(plan.entries[0].priority, PlanEntryPriority::High); + assert_eq!(plan.entries[1].content, "ok 2"); + assert_eq!(plan.entries[1].status, PlanEntryStatus::Completed); + } + + #[test] + fn plan_collapses_outer_shape_errors_to_empty_entries() { + // `DefaultOnError` should swallow outer-shape failures so a producer + // sending the wrong type for `entries` does not break consumers. + // `entries` is required on the wire (no `#[serde(default)]`), so a + // missing key still errors; that's intentional and documented here. + let cases = [ + json!({ "entries": null }), + json!({ "entries": "oops" }), + json!({ "entries": {"k": 1} }), + json!({ "entries": 42 }), + ]; + for input in cases { + let plan: Plan = serde_json::from_value(input.clone()).unwrap_or_else(|e| { + panic!("expected Plan to deserialize from {input}: {e}"); + }); + assert!( + plan.entries.is_empty(), + "expected empty entries for {input}, got {:?}", + plan.entries + ); + } + + // Missing key is still an error - the field is required. + let err = serde_json::from_value::(json!({})).unwrap_err(); + assert!( + err.to_string().contains("entries"), + "expected missing-field error to mention `entries`, got: {err}" + ); + } + + #[test] + fn plan_preserves_meta_when_present() { + let mut meta = Meta::new(); + meta.insert("k".to_string(), json!("v")); + let value = Plan::new(vec![entry()]).meta(meta.clone()); + + let serialized = serde_json::to_value(&value).unwrap(); + assert_eq!(serialized["_meta"], json!({"k": "v"})); + let parsed: Plan = serde_json::from_value(serialized).unwrap(); + assert_eq!(parsed.meta, Some(meta)); + } +} diff --git a/src/v2/protocol_level.rs b/src/v2/protocol_level.rs index 46503384c..da3d09b4e 100644 --- a/src/v2/protocol_level.rs +++ b/src/v2/protocol_level.rs @@ -121,3 +121,73 @@ impl ProtocolLevelNotification { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::v2::RequestId; + use serde_json::json; + + #[test] + fn cancel_request_method_name_is_dollar_namespace() { + // `$/cancel_request` is the protocol-level method name that all sides + // are expected to recognize (or safely ignore). Renaming this is a + // wire-format break. + assert_eq!(CANCEL_REQUEST_METHOD_NAME, "$/cancel_request"); + assert_eq!( + PROTOCOL_LEVEL_METHOD_NAMES.cancel_request, + CANCEL_REQUEST_METHOD_NAME + ); + } + + #[test] + fn protocol_level_notification_method_returns_cancel_request_name() { + let notif = ProtocolLevelNotification::CancelRequestNotification( + CancelRequestNotification::new(RequestId::Number(7)), + ); + assert_eq!(notif.method(), CANCEL_REQUEST_METHOD_NAME); + } + + #[test] + fn cancel_request_notification_roundtrip_omits_meta_when_unset() { + let notif = CancelRequestNotification::new(RequestId::Str("req_1".to_string())); + let serialized = serde_json::to_value(¬if).unwrap(); + assert_eq!(serialized, json!({"requestId": "req_1"})); + + let parsed: CancelRequestNotification = serde_json::from_value(serialized).unwrap(); + assert_eq!(parsed, notif); + } + + #[test] + fn cancel_request_notification_preserves_meta_when_set() { + let mut meta = Meta::new(); + meta.insert("trace".to_string(), json!("abc123")); + let notif = CancelRequestNotification::new(RequestId::Number(42)).meta(meta.clone()); + + let serialized = serde_json::to_value(¬if).unwrap(); + assert_eq!( + serialized, + json!({"requestId": 42, "_meta": {"trace": "abc123"}}) + ); + + let parsed: CancelRequestNotification = serde_json::from_value(serialized).unwrap(); + assert_eq!(parsed.meta, Some(meta)); + } + + #[test] + fn cancel_request_accepts_all_request_id_shapes() { + let cases = [ + (RequestId::Null, json!(null)), + (RequestId::Number(0), json!(0)), + (RequestId::Number(-1), json!(-1)), + (RequestId::Str("hex-id".to_string()), json!("hex-id")), + ]; + for (id, expected_id_json) in cases { + let notif = CancelRequestNotification::new(id.clone()); + let serialized = serde_json::to_value(¬if).unwrap(); + assert_eq!(serialized["requestId"], expected_id_json, "id: {id:?}"); + let parsed: CancelRequestNotification = serde_json::from_value(serialized).unwrap(); + assert_eq!(parsed.request_id, id); + } + } +} diff --git a/src/v2/tool_call.rs b/src/v2/tool_call.rs index dd04f3968..5639726b7 100644 --- a/src/v2/tool_call.rs +++ b/src/v2/tool_call.rs @@ -673,3 +673,284 @@ impl ToolCallLocation { self } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::v2::{ContentBlock, ErrorCode, TextContent}; + use serde_json::json; + + fn full_tool_call() -> ToolCall { + ToolCall::new("tc_1", "edit file") + .kind(ToolKind::Edit) + .status(ToolCallStatus::InProgress) + .content(vec![ToolCallContent::Diff( + Diff::new("/tmp/a.rs", "new").old_text("old"), + )]) + .locations(vec![ToolCallLocation::new("/tmp/a.rs").line(7)]) + .raw_input(json!({"path": "/tmp/a.rs"})) + .raw_output(json!({"ok": true})) + } + + #[test] + fn tool_call_default_kind_and_status_are_omitted() { + let value = ToolCall::new("tc", "title"); + let serialized = serde_json::to_value(&value).unwrap(); + let obj = serialized.as_object().unwrap(); + assert!( + !obj.contains_key("kind"), + "kind should be skipped: {serialized}" + ); + assert!( + !obj.contains_key("status"), + "status should be skipped: {serialized}" + ); + assert!(!obj.contains_key("content")); + assert!(!obj.contains_key("locations")); + assert!(!obj.contains_key("rawInput")); + assert!(!obj.contains_key("rawOutput")); + assert!(!obj.contains_key("_meta")); + } + + #[test] + fn tool_kind_unknown_variants_decode_to_other() { + let kind: ToolKind = serde_json::from_str("\"future_kind\"").unwrap(); + assert_eq!(kind, ToolKind::Other); + assert_eq!( + serde_json::to_value(ToolKind::Other).unwrap(), + json!("other") + ); + } + + #[test] + fn tool_call_status_pending_is_default() { + assert_eq!(ToolCallStatus::default(), ToolCallStatus::Pending); + assert_eq!( + serde_json::to_value(ToolCallStatus::InProgress).unwrap(), + json!("in_progress") + ); + let parsed: ToolCallStatus = serde_json::from_str("\"completed\"").unwrap(); + assert_eq!(parsed, ToolCallStatus::Completed); + } + + #[test] + fn tool_call_roundtrip_preserves_all_fields() { + let value = full_tool_call(); + let serialized = serde_json::to_value(&value).unwrap(); + let parsed: ToolCall = serde_json::from_value(serialized).unwrap(); + assert_eq!(parsed, value); + } + + #[test] + fn tool_call_update_only_overwrites_present_fields() { + let mut call = full_tool_call(); + let fields = ToolCallUpdateFields::new().status(ToolCallStatus::Completed); + call.update(fields); + + assert_eq!(call.status, ToolCallStatus::Completed); + assert_eq!(call.title, "edit file"); + assert_eq!(call.kind, ToolKind::Edit); + assert_eq!(call.content.len(), 1); + assert_eq!(call.locations.len(), 1); + assert_eq!(call.raw_input, Some(json!({"path": "/tmp/a.rs"}))); + } + + #[test] + fn tool_call_update_replaces_collections_not_extends() { + let mut call = full_tool_call(); + let fields = ToolCallUpdateFields::new() + .content(vec![ToolCallContent::Content(Content::new( + ContentBlock::Text(TextContent::new("only new")), + ))]) + .locations(vec![ToolCallLocation::new("/other")]); + call.update(fields); + + assert_eq!(call.content.len(), 1); + match &call.content[0] { + ToolCallContent::Content(c) => match &c.content { + ContentBlock::Text(t) => assert_eq!(t.text, "only new"), + _ => panic!("expected text content"), + }, + _ => panic!("expected Content variant after replacement"), + } + assert_eq!(call.locations.len(), 1); + assert_eq!(call.locations[0].path, std::path::PathBuf::from("/other")); + } + + #[test] + fn tool_call_try_from_update_requires_title() { + let update = ToolCallUpdate::new("tc_1", ToolCallUpdateFields::new()); + let err = ToolCall::try_from(update).unwrap_err(); + assert_eq!(err.code, ErrorCode::InvalidParams); + assert_eq!( + err.data.as_ref().and_then(|v| v.as_str()), + Some("title is required for a tool call"), + ); + } + + #[test] + fn tool_call_try_from_update_fills_defaults_for_missing_optionals() { + let update = ToolCallUpdate::new( + "tc_1", + ToolCallUpdateFields::new() + .title("hello") + .raw_input(json!({"x": 1})), + ); + + let call = ToolCall::try_from(update).unwrap(); + assert_eq!(call.tool_call_id, ToolCallId::new("tc_1")); + assert_eq!(call.title, "hello"); + assert_eq!(call.kind, ToolKind::default()); + assert_eq!(call.status, ToolCallStatus::default()); + assert!(call.content.is_empty()); + assert!(call.locations.is_empty()); + assert_eq!(call.raw_input, Some(json!({"x": 1}))); + assert_eq!(call.raw_output, None); + } + + #[test] + fn tool_call_to_update_preserves_every_field() { + let call = full_tool_call(); + let update: ToolCallUpdate = call.clone().into(); + assert_eq!(update.tool_call_id, call.tool_call_id); + assert_eq!(update.fields.title.as_deref(), Some("edit file")); + assert_eq!(update.fields.kind, Some(ToolKind::Edit)); + assert_eq!(update.fields.status, Some(ToolCallStatus::InProgress)); + assert_eq!(update.fields.content.as_ref().map(Vec::len), Some(1)); + assert_eq!(update.fields.locations.as_ref().map(Vec::len), Some(1)); + assert_eq!(update.fields.raw_input, Some(json!({"path": "/tmp/a.rs"}))); + assert_eq!(update.fields.raw_output, Some(json!({"ok": true}))); + + let rebuilt = ToolCall::try_from(update).unwrap(); + assert_eq!(rebuilt, call); + } + + #[test] + fn tool_call_update_flattens_fields_on_wire() { + let update = ToolCallUpdate::new( + "tc_1", + ToolCallUpdateFields::new() + .status(ToolCallStatus::Completed) + .title("done"), + ); + let serialized = serde_json::to_value(&update).unwrap(); + assert_eq!(serialized["toolCallId"], json!("tc_1")); + assert_eq!(serialized["status"], json!("completed")); + assert_eq!(serialized["title"], json!("done")); + assert!( + serialized.get("fields").is_none(), + "fields should be flattened" + ); + } + + #[test] + fn tool_call_content_discriminator_is_type_snake_case() { + let diff = ToolCallContent::Diff(Diff::new("/p", "new")); + let json = serde_json::to_value(&diff).unwrap(); + assert_eq!(json["type"], json!("diff")); + + let parsed: ToolCallContent = serde_json::from_value(json!({ + "type": "content", + "content": {"type": "text", "text": "hi"} + })) + .unwrap(); + match parsed { + ToolCallContent::Content(c) => match c.content { + ContentBlock::Text(t) => assert_eq!(t.text, "hi"), + _ => panic!("expected text"), + }, + _ => panic!("expected Content variant"), + } + + let parsed: ToolCallContent = serde_json::from_value(json!({ + "type": "terminal", + "terminalId": "term_1", + })) + .unwrap(); + match parsed { + ToolCallContent::Terminal(t) => assert_eq!(t.terminal_id.0.as_ref(), "term_1"), + _ => panic!("expected Terminal variant"), + } + } + + #[test] + fn tool_call_skips_malformed_content_and_locations() { + let input = json!({ + "toolCallId": "tc_1", + "title": "hi", + "content": [ + {"type": "content", "content": {"type": "text", "text": "ok"}}, + {"type": "diff", "missingPath": true}, + "not even an object", + {"type": "totally_unknown_kind"}, + {"type": "diff", "path": "/p", "newText": "n"}, + ], + "locations": [ + {"path": "/ok"}, + {"path": 42}, + "nope", + ] + }); + + let call: ToolCall = serde_json::from_value(input).unwrap(); + assert_eq!(call.content.len(), 2); + match &call.content[0] { + ToolCallContent::Content(_) => {} + _ => panic!("expected text content first"), + } + match &call.content[1] { + ToolCallContent::Diff(d) => assert_eq!(d.new_text, "n"), + _ => panic!("expected diff second"), + } + assert_eq!(call.locations.len(), 1); + assert_eq!(call.locations[0].path, std::path::PathBuf::from("/ok")); + } + + #[test] + fn tool_call_treats_outer_shape_errors_as_empty_collections() { + let input = json!({ + "toolCallId": "tc_1", + "title": "hi", + "content": "oops", + "locations": {"k": 1} + }); + let call: ToolCall = serde_json::from_value(input).unwrap(); + assert!(call.content.is_empty()); + assert!(call.locations.is_empty()); + } + + #[test] + fn tool_call_update_fields_tolerate_unknown_kind_and_status() { + let fields: ToolCallUpdateFields = serde_json::from_value(json!({ + "kind": "totally_new_kind", + "status": "totally_new_status", + })) + .unwrap(); + assert_eq!(fields.kind, Some(ToolKind::Other)); + assert_eq!(fields.status, None); + } + + #[test] + fn diff_new_leaves_old_text_unset() { + let d = Diff::new("/p", "new"); + assert_eq!(d.old_text, None); + let serialized = serde_json::to_value(&d).unwrap(); + assert!(serialized.as_object().unwrap().get("oldText").is_none()); + } + + #[test] + fn tool_call_content_from_content_block_wraps_in_content_variant() { + let block: ContentBlock = ContentBlock::Text(TextContent::new("hi")); + let tcc: ToolCallContent = block.into(); + match tcc { + ToolCallContent::Content(_) => {} + _ => panic!("expected Content variant from blanket From impl"), + } + + let from_diff: ToolCallContent = Diff::new("/p", "n").into(); + match from_diff { + ToolCallContent::Diff(_) => {} + _ => panic!("expected Diff variant from From"), + } + } +}