Skip to content

Commit 3fc65ba

Browse files
authored
fix(compat): introduce SidecarMeta struct with serde(default) for .meta.json parsing (#109)
Replaces the ad-hoc serde_json::Value navigation in read_agent_type with a typed SidecarMeta struct where every field carries #[serde(default)] or is Option<T>. This ensures that sessions written before a field was introduced continue to parse without error as Claude Code adds new optional fields to the sidecar schema. Adds a doc-comment on SidecarMeta that documents the backwards-compatibility rule so future contributors cannot accidentally add a bare required field. Adds 6 regression tests (minimal file, empty object, unknown future fields, missing file, and round-trip for known and future-extra-field files). Fixes #107
1 parent b7ea662 commit 3fc65ba

1 file changed

Lines changed: 97 additions & 7 deletions

File tree

src-tauri/src/parser/subagent.rs

Lines changed: 97 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use chrono::{DateTime, Utc};
2-
use serde::Serialize;
2+
use serde::{Deserialize, Serialize};
33
use serde_json::Value;
44
use std::collections::{HashMap, HashSet};
55
use std::fs;
@@ -369,16 +369,35 @@ fn build_subagent_process(
369369
}
370370
}
371371

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+
372395
/// Read agentType from a .meta.json file next to a subagent .jsonl.
373396
fn read_agent_type(meta_path: &str) -> String {
374397
fs::read_to_string(meta_path)
375398
.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)
382401
.unwrap_or_default()
383402
}
384403

@@ -1510,4 +1529,75 @@ mod tests {
15101529
"orphan should extract skill name from prompt"
15111530
);
15121531
}
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+
}
15131603
}

0 commit comments

Comments
 (0)