max / goingson
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
5 files changed,
+198 insertions,
-17 deletions
| @@ -205,10 +205,25 @@ | |||
| 205 | 205 | return Ok((Recurrence::None, None)); | |
| 206 | 206 | } | |
| 207 | 207 | ||
| 208 | - | if let Some(word) = raw.as_str() { | |
| 209 | - | let pattern: Recurrence = parse_enum(tool, "recurrence", word, RECURRENCE_PATTERNS)?; | |
| 210 | - | return Ok((pattern, None)); | |
| 211 | - | } | |
| 208 | + | // A client whose schema validator does not carry the object arm sends the | |
| 209 | + | // rich rule stringified. Re-parse rather than failing with a message that | |
| 210 | + | // points at the enum: the whole object would appear where a word belongs, | |
| 211 | + | // which is exactly the wrong thing to show the caller. | |
| 212 | + | let reparsed; | |
| 213 | + | let raw = match raw.as_str() { | |
| 214 | + | Some(word) => match serde_json::from_str::<Value>(word) { | |
| 215 | + | Ok(v) if v.is_object() => { | |
| 216 | + | reparsed = v; | |
| 217 | + | &reparsed | |
| 218 | + | } | |
| 219 | + | _ => { | |
| 220 | + | let pattern: Recurrence = | |
| 221 | + | parse_enum(tool, "recurrence", word, RECURRENCE_PATTERNS)?; | |
| 222 | + | return Ok((pattern, None)); | |
| 223 | + | } | |
| 224 | + | }, | |
| 225 | + | None => raw, | |
| 226 | + | }; | |
| 212 | 227 | ||
| 213 | 228 | let obj = raw.as_object().ok_or_else(|| { | |
| 214 | 229 | invalid(format!( |
| @@ -257,6 +257,126 @@ | |||
| 257 | 257 | assert!(done["next_task"].is_null(), "a cleared rule must not recur"); | |
| 258 | 258 | } | |
| 259 | 259 | ||
| 260 | + | #[tokio::test] | |
| 261 | + | async fn every_recurrence_schema_declares_the_object_arm() { | |
| 262 | + | let reg = tools::registry(Arc::new(Ctx::new(seed_db()))); | |
| 263 | + | ||
| 264 | + | // The bug this guards: the fragment carried a `description` and no `type`, | |
| 265 | + | // so a client serialised the rich rule as a string and the object branch of | |
| 266 | + | // `parse_recurrence` was unreachable from every MCP client. | |
| 267 | + | for tool in [ | |
| 268 | + | "create_task", | |
| 269 | + | "bulk_import_tasks", | |
| 270 | + | "update_task", | |
| 271 | + | "create_event", | |
| 272 | + | "bulk_import_events", | |
| 273 | + | "update_event", | |
| 274 | + | ] { | |
| 275 | + | let schema = reg | |
| 276 | + | .get(tool) | |
| 277 | + | .unwrap_or_else(|| panic!("{tool} registered")) | |
| 278 | + | .input_schema(); | |
| 279 | + | let text = schema.to_string(); | |
| 280 | + | let field = find_recurrence(&schema) | |
| 281 | + | .unwrap_or_else(|| panic!("{tool} declares a recurrence field, schema was {text}")); | |
| 282 | + | ||
| 283 | + | assert!( | |
| 284 | + | field.get("type").is_some(), | |
| 285 | + | "{tool}'s recurrence needs a type, or the object arm is unreachable" | |
| 286 | + | ); | |
| 287 | + | let arms = field["oneOf"] | |
| 288 | + | .as_array() | |
| 289 | + | .unwrap_or_else(|| panic!("{tool}'s recurrence needs a oneOf")); | |
| 290 | + | let object_arm = arms | |
| 291 | + | .iter() | |
| 292 | + | .find(|a| a["type"] == "object") | |
| 293 | + | .unwrap_or_else(|| panic!("{tool}'s recurrence needs an object arm")); | |
| 294 | + | assert_eq!( | |
| 295 | + | object_arm["properties"]["interval"]["type"], "integer", | |
| 296 | + | "{tool}'s interval must be typed" | |
| 297 | + | ); | |
| 298 | + | assert_eq!(object_arm["properties"]["pattern"]["type"], "string"); | |
| 299 | + | } | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | /// The `recurrence` subschema, wherever a tool nests it (the bulk tools put | |
| 303 | + | /// their item fields one level down). | |
| 304 | + | fn find_recurrence(schema: &Value) -> Option<&Value> { | |
| 305 | + | match schema { | |
| 306 | + | Value::Object(map) => { | |
| 307 | + | if let Some(found) = map.get("recurrence") { | |
| 308 | + | return Some(found); | |
| 309 | + | } | |
| 310 | + | map.values().find_map(find_recurrence) | |
| 311 | + | } | |
| 312 | + | Value::Array(items) => items.iter().find_map(find_recurrence), | |
| 313 | + | _ => None, | |
| 314 | + | } | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | #[tokio::test] | |
| 318 | + | async fn an_annual_rule_round_trips_through_the_wire_shape() { | |
| 319 | + | let reg = tools::registry(Arc::new(Ctx::new(seed_db()))); | |
| 320 | + | ||
| 321 | + | // There is no `Yearly` word. Annual is Monthly every twelfth month, and it | |
| 322 | + | // is only reachable if the object arrives as an object. | |
| 323 | + | let created = call( | |
| 324 | + | ®, | |
| 325 | + | "create_task", | |
| 326 | + | json!({ | |
| 327 | + | "description": "re-check the payments landscape", | |
| 328 | + | "due": "2028-01-15", | |
| 329 | + | "recurrence": { "pattern": "Monthly", "interval": 12 }, | |
| 330 | + | }), | |
| 331 | + | ) | |
| 332 | + | .await; | |
| 333 | + | let id = created["id"].as_str().unwrap().to_string(); | |
| 334 | + | ||
| 335 | + | let got = call(®, "get_task", json!({ "id": id })).await; | |
| 336 | + | assert_eq!(got["recurrence"]["pattern"], "Monthly"); | |
| 337 | + | assert_eq!(got["recurrence"]["interval"], 12); | |
| 338 | + | ||
| 339 | + | // And through update, which takes the same fragment from the same helper. | |
| 340 | + | let updated = call( | |
| 341 | + | ®, | |
| 342 | + | "update_task", | |
| 343 | + | json!({ "id": id, "recurrence": { "pattern": "Monthly", "interval": 6 } }), | |
| 344 | + | ) | |
| 345 | + | .await; | |
| 346 | + | assert_eq!(updated["recurrence"]["interval"], 6); | |
| 347 | + | } | |
| 348 | + | ||
| 349 | + | #[tokio::test] | |
| 350 | + | async fn a_stringified_rule_object_is_reparsed_rather_than_refused() { | |
| 351 | + | let reg = tools::registry(Arc::new(Ctx::new(seed_db()))); | |
| 352 | + | ||
| 353 | + | // Belt and braces for a client whose validator drops the object arm: the | |
| 354 | + | // whole rule arrives as one string. Failing here would report the object | |
| 355 | + | // as an unknown pattern word, which points the caller at the wrong thing. | |
| 356 | + | let created = call( | |
| 357 | + | ®, | |
| 358 | + | "create_task", | |
| 359 | + | json!({ | |
| 360 | + | "description": "quarterly review", | |
| 361 | + | "recurrence": "{\"pattern\":\"Monthly\",\"interval\":3}", | |
| 362 | + | }), | |
| 363 | + | ) | |
| 364 | + | .await; | |
| 365 | + | let got = call(®, "get_task", json!({ "id": created["id"] })).await; | |
| 366 | + | assert_eq!(got["recurrence"]["pattern"], "Monthly"); | |
| 367 | + | assert_eq!(got["recurrence"]["interval"], 3); | |
| 368 | + | ||
| 369 | + | // A word is still a word, and a string that is neither still fails as one. | |
| 370 | + | let plain = call( | |
| 371 | + | ®, | |
| 372 | + | "create_task", | |
| 373 | + | json!({ "description": "standup", "recurrence": "Daily" }), | |
| 374 | + | ) | |
| 375 | + | .await; | |
| 376 | + | let got = call(®, "get_task", json!({ "id": plain["id"] })).await; | |
| 377 | + | assert_eq!(got["recurrence"]["pattern"], "Daily"); | |
| 378 | + | } | |
| 379 | + | ||
| 260 | 380 | #[tokio::test] | |
| 261 | 381 | async fn recurrence_rejects_a_bad_rule_instead_of_defaulting() { | |
| 262 | 382 | let reg = tools::registry(Arc::new(Ctx::new(seed_db()))); |
| @@ -44,7 +44,7 @@ | |||
| 44 | 44 | use serde_json::{Value, json}; | |
| 45 | 45 | ||
| 46 | 46 | use super::contact::{ContactCache, contact_arg, contact_fields}; | |
| 47 | - | use super::{project_id_arg, project_id_field}; | |
| 47 | + | use super::{project_id_arg, project_id_field, recurrence_field}; | |
| 48 | 48 | use crate::caps; | |
| 49 | 49 | use crate::context::Ctx; | |
| 50 | 50 | use crate::convert::{ | |
| @@ -424,9 +424,7 @@ | |||
| 424 | 424 | "description": { "type": "string" }, | |
| 425 | 425 | "project_id": project_id_field(), | |
| 426 | 426 | "location": { "type": "string" }, | |
| 427 | - | "recurrence": { | |
| 428 | - | "description": "Either a word (None|Daily|Weekly|Monthly) or an object {pattern, interval?, weekdays?, monthly_spec?, until?}. weekdays are 0=Mon..6=Sun. monthly_spec is {type: dayOfMonth, day} or {type: nthWeekday, week, weekday} (week -1 = last). until is RFC 3339 and ends the series inclusively; omit it to repeat forever." | |
| 429 | - | }, | |
| 427 | + | "recurrence": recurrence_field(), | |
| 430 | 428 | "tz_kind": { | |
| 431 | 429 | "type": "string", | |
| 432 | 430 | "description": "relative (a wall clock that follows the user, e.g. a daily routine) | local (a wall clock anchored to `timezone`, correct across that zone's DST) | absolute (a fixed instant). Defaults to absolute." |
| @@ -151,3 +151,59 @@ | |||
| 151 | 151 | "description": "Project id (UUID) from list_projects. Names are labels and can be renamed, so they are not accepted here." | |
| 152 | 152 | }) | |
| 153 | 153 | } | |
| 154 | + | ||
| 155 | + | /// The `recurrence` schema fragment, worded once for the task and event write | |
| 156 | + | /// surfaces, which store the identical rule type. | |
| 157 | + | /// | |
| 158 | + | /// The object arm has to declare its own types. An untyped property is handed | |
| 159 | + | /// to the server as a string, so a client sending the rich rule stringified the | |
| 160 | + | /// whole object and `parse_recurrence` never reached its object branch. | |
| 161 | + | pub(super) fn recurrence_field() -> Value { | |
| 162 | + | json!({ | |
| 163 | + | // The union is stated twice on purpose. `oneOf` is the precise form, | |
| 164 | + | // and a validator that only reads `type` still learns that an object | |
| 165 | + | // is allowed rather than defaulting the value to a string. | |
| 166 | + | "type": ["string", "object"], | |
| 167 | + | "description": "Either a word (None|Daily|Weekly|Monthly) or an object {pattern, interval?, weekdays?, monthly_spec?, until?}. weekdays are 0=Mon..6=Sun. monthly_spec is {type: dayOfMonth, day} or {type: nthWeekday, week, weekday} (week -1 = last). until is RFC 3339 and ends the series inclusively; omit it to repeat forever. Completing a recurring task opens its successor rather than editing it, and the instance landing on until opens none. An annual rule is {pattern: Monthly, interval: 12}; there is no Yearly word.", | |
| 168 | + | "oneOf": [ | |
| 169 | + | { | |
| 170 | + | "type": "string", | |
| 171 | + | "enum": ["None", "Daily", "Weekly", "Monthly"] | |
| 172 | + | }, | |
| 173 | + | { | |
| 174 | + | "type": "object", | |
| 175 | + | "required": ["pattern"], | |
| 176 | + | "properties": { | |
| 177 | + | "pattern": { | |
| 178 | + | "type": "string", | |
| 179 | + | "enum": ["None", "Daily", "Weekly", "Monthly"] | |
| 180 | + | }, | |
| 181 | + | "interval": { | |
| 182 | + | "type": "integer", | |
| 183 | + | "minimum": 1, | |
| 184 | + | "description": "Repeat every N patterns. Defaults to 1; interval 12 on Monthly is annual." | |
| 185 | + | }, | |
| 186 | + | "weekdays": { | |
| 187 | + | "type": "array", | |
| 188 | + | "items": { "type": "integer", "minimum": 0, "maximum": 6 }, | |
| 189 | + | "description": "0=Mon .. 6=Sun." | |
| 190 | + | }, | |
| 191 | + | "monthly_spec": { | |
| 192 | + | "type": "object", | |
| 193 | + | "description": "{type: dayOfMonth, day} or {type: nthWeekday, week, weekday}; week -1 is the last.", | |
| 194 | + | "properties": { | |
| 195 | + | "type": { "type": "string", "enum": ["dayOfMonth", "day_of_month", "nthWeekday", "nth_weekday"] }, | |
| 196 | + | "day": { "type": "integer", "minimum": 1, "maximum": 31 }, | |
| 197 | + | "week": { "type": "integer", "minimum": -1, "maximum": 5 }, | |
| 198 | + | "weekday": { "type": "integer", "minimum": 0, "maximum": 6 } | |
| 199 | + | } | |
| 200 | + | }, | |
| 201 | + | "until": { | |
| 202 | + | "type": "string", | |
| 203 | + | "description": "RFC 3339; the last instant the series may land on." | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | } | |
| 207 | + | ] | |
| 208 | + | }) | |
| 209 | + | } |
| @@ -22,7 +22,7 @@ | |||
| 22 | 22 | use kberg::{Error, Result, Tool, ToolCallResult, ToolKind}; | |
| 23 | 23 | use serde_json::{Value, json}; | |
| 24 | 24 | ||
| 25 | - | use super::{project_id_arg, project_id_field}; | |
| 25 | + | use super::{project_id_arg, project_id_field, recurrence_field}; | |
| 26 | 26 | use crate::caps; | |
| 27 | 27 | use crate::context::Ctx; | |
| 28 | 28 | use crate::convert::{ | |
| @@ -31,14 +31,6 @@ | |||
| 31 | 31 | subtask_row, task_row, task_summary_row, | |
| 32 | 32 | }; | |
| 33 | 33 | ||
| 34 | - | /// The `recurrence` schema fragment, identical across the task write tools and | |
| 35 | - | /// worded the same as the event surface's, since it is the same rule type. | |
| 36 | - | fn recurrence_field() -> Value { | |
| 37 | - | json!({ | |
| 38 | - | "description": "Either a word (None|Daily|Weekly|Monthly) or an object {pattern, interval?, weekdays?, monthly_spec?, until?}. weekdays are 0=Mon..6=Sun. monthly_spec is {type: dayOfMonth, day} or {type: nthWeekday, week, weekday} (week -1 = last). until is RFC 3339 and ends the series inclusively; omit it to repeat forever. Completing a recurring task opens its successor rather than editing it, and the instance landing on until opens none." | |
| 39 | - | }) | |
| 40 | - | } | |
| 41 | - | ||
| 42 | 34 | fn fail(tool: &str, e: impl std::fmt::Display) -> Error { | |
| 43 | 35 | Error::ToolFailed { | |
| 44 | 36 | tool: tool.to_string(), |