|
1 | 1 | use chrono::{DateTime, Utc}; |
2 | | -use serde::Serialize; |
| 2 | +use serde::{Deserialize, Serialize}; |
3 | 3 | use serde_json::Value; |
4 | 4 | use std::collections::{HashMap, HashSet}; |
5 | 5 | use std::fs; |
@@ -369,16 +369,35 @@ fn build_subagent_process( |
369 | 369 | } |
370 | 370 | } |
371 | 371 |
|
| 372 | +/// Typed representation of a `.meta.json` sidecar file written by Claude Code |
| 373 | +/// alongside every session JSONL. |
| 374 | +/// |
| 375 | +/// **Backwards-compatibility rule**: every field here MUST carry `#[serde(default)]` |
| 376 | +/// or be `Option<T>`. Claude Code's sidecar schema is in active flux — new fields |
| 377 | +/// are added regularly, and older session files simply omit them. A bare required |
| 378 | +/// field would cause a parse failure on any session written before that field was |
| 379 | +/// introduced. (This was the root cause of the `/insights` crash fixed in |
| 380 | +/// Claude Code v2.1.149.) |
| 381 | +#[derive(Debug, Deserialize, Default)] |
| 382 | +struct SidecarMeta { |
| 383 | + #[serde(default, rename = "agentType")] |
| 384 | + agent_type: String, |
| 385 | + // Fields added in Claude Code v2.1.149+ (and any future additions) must |
| 386 | + // follow the same pattern: `Option<T>` or `#[serde(default)]`. |
| 387 | + #[serde(default, rename = "sessionTitle")] |
| 388 | + session_title: Option<String>, |
| 389 | + #[serde(default)] |
| 390 | + model: Option<String>, |
| 391 | + #[serde(default, rename = "gitBranch")] |
| 392 | + git_branch: Option<String>, |
| 393 | +} |
| 394 | + |
372 | 395 | /// Read agentType from a .meta.json file next to a subagent .jsonl. |
373 | 396 | fn read_agent_type(meta_path: &str) -> String { |
374 | 397 | fs::read_to_string(meta_path) |
375 | 398 | .ok() |
376 | | - .and_then(|s| serde_json::from_str::<Value>(&s).ok()) |
377 | | - .and_then(|v| { |
378 | | - v.get("agentType") |
379 | | - .and_then(|a| a.as_str()) |
380 | | - .map(String::from) |
381 | | - }) |
| 399 | + .and_then(|s| serde_json::from_str::<SidecarMeta>(&s).ok()) |
| 400 | + .map(|m| m.agent_type) |
382 | 401 | .unwrap_or_default() |
383 | 402 | } |
384 | 403 |
|
@@ -1510,4 +1529,75 @@ mod tests { |
1510 | 1529 | "orphan should extract skill name from prompt" |
1511 | 1530 | ); |
1512 | 1531 | } |
| 1532 | + |
| 1533 | + // Regression tests for issue #107: SidecarMeta must tolerate missing optional fields |
| 1534 | + // so that older sessions (written before new fields were introduced) continue to parse. |
| 1535 | + |
| 1536 | + #[test] |
| 1537 | + fn sidecar_meta_minimal_json_parses_without_error() { |
| 1538 | + // Oldest possible meta file — only agentType, nothing else. |
| 1539 | + let json = r#"{"agentType":"general-purpose"}"#; |
| 1540 | + let m: SidecarMeta = serde_json::from_str(json).expect("must not fail on minimal meta"); |
| 1541 | + assert_eq!(m.agent_type, "general-purpose"); |
| 1542 | + assert!(m.session_title.is_none()); |
| 1543 | + assert!(m.model.is_none()); |
| 1544 | + assert!(m.git_branch.is_none()); |
| 1545 | + } |
| 1546 | + |
| 1547 | + #[test] |
| 1548 | + fn sidecar_meta_empty_object_parses_without_error() { |
| 1549 | + // Completely empty meta file (edge case from corrupted/truncated writes). |
| 1550 | + let json = r#"{}"#; |
| 1551 | + let m: SidecarMeta = serde_json::from_str(json).expect("empty meta must not fail"); |
| 1552 | + assert_eq!(m.agent_type, ""); |
| 1553 | + assert!(m.session_title.is_none()); |
| 1554 | + } |
| 1555 | + |
| 1556 | + #[test] |
| 1557 | + fn sidecar_meta_unknown_new_fields_are_ignored() { |
| 1558 | + // Simulates a future Claude Code version adding fields we have not yet mapped. |
| 1559 | + // serde must ignore unknown fields rather than failing. |
| 1560 | + let json = r#"{"agentType":"codex","unknownFutureField":42,"anotherNew":{"nested":true}}"#; |
| 1561 | + let m: SidecarMeta = serde_json::from_str(json).expect("unknown fields must not fail"); |
| 1562 | + assert_eq!(m.agent_type, "codex"); |
| 1563 | + } |
| 1564 | + |
| 1565 | + #[test] |
| 1566 | + fn read_agent_type_returns_empty_for_missing_meta_file() { |
| 1567 | + let result = read_agent_type("/nonexistent/path/agent-abc.meta.json"); |
| 1568 | + assert_eq!(result, "", "missing meta file must return empty string"); |
| 1569 | + } |
| 1570 | + |
| 1571 | + #[test] |
| 1572 | + fn read_agent_type_returns_empty_for_empty_object() { |
| 1573 | + let dir = tempfile::tempdir().unwrap(); |
| 1574 | + let path = dir.path().join("agent-x.meta.json"); |
| 1575 | + std::fs::write(&path, r#"{}"#).unwrap(); |
| 1576 | + let result = read_agent_type(path.to_str().unwrap()); |
| 1577 | + assert_eq!(result, ""); |
| 1578 | + } |
| 1579 | + |
| 1580 | + #[test] |
| 1581 | + fn read_agent_type_parses_known_field() { |
| 1582 | + let dir = tempfile::tempdir().unwrap(); |
| 1583 | + let path = dir.path().join("agent-y.meta.json"); |
| 1584 | + std::fs::write(&path, r#"{"agentType":"explore"}"#).unwrap(); |
| 1585 | + let result = read_agent_type(path.to_str().unwrap()); |
| 1586 | + assert_eq!(result, "explore"); |
| 1587 | + } |
| 1588 | + |
| 1589 | + #[test] |
| 1590 | + fn read_agent_type_tolerates_extra_fields_from_new_claude_code_version() { |
| 1591 | + // Claude Code v2.1.149+ may add sessionTitle, model, gitBranch, etc. |
| 1592 | + // Parsers must not panic on these new optional fields. |
| 1593 | + let dir = tempfile::tempdir().unwrap(); |
| 1594 | + let path = dir.path().join("agent-z.meta.json"); |
| 1595 | + std::fs::write( |
| 1596 | + &path, |
| 1597 | + r#"{"agentType":"general-purpose","sessionTitle":"My session","model":"claude-opus-4-7","gitBranch":"main","unknownFuture":true}"#, |
| 1598 | + ) |
| 1599 | + .unwrap(); |
| 1600 | + let result = read_agent_type(path.to_str().unwrap()); |
| 1601 | + assert_eq!(result, "general-purpose"); |
| 1602 | + } |
1513 | 1603 | } |
0 commit comments