Skip to content

Commit f7eb799

Browse files
committed
fix(parser): handle parentUuid: null in subagent JSONL entries
parse_entry skipped any line where serde_json failed to deserialize. The first entry in every subagent JSONL file has parentUuid: null, which caused serde to fail because #[serde(default)] only applies to absent fields — not fields explicitly set to null. Result: the skill-prompt user message (first line) was silently dropped. proc.prompt stayed empty, orphan_description_from_prompt returned "", and subagent_desc was never populated — so detail-item__summary showed nothing for all Skill-forked agents. Fix: add a null_as_default deserializer for parent_uuid that treats null as an empty string (same as absent). Add a regression test in entry.rs and an end-to-end integration test (skill_forked_agents_have_ subagent_desc_populated) that loads the real session 3f97c7ca and asserts all 6 agent items carry their skill name as subagent_desc.
1 parent 89b4377 commit f7eb799

2 files changed

Lines changed: 95 additions & 1 deletion

File tree

src-tauri/src/convert.rs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -810,4 +810,69 @@ mod tests {
810810
assert_eq!(result.len(), 1);
811811
assert_eq!(result[0].subagent_prompt, "");
812812
}
813+
814+
/// End-to-end test using the real JSONL session 3f97c7ca to verify that
815+
/// Skill-forked agent summaries (detail-item__summary) are populated.
816+
/// The session has no assistant messages — all 6 agents are orphans linked
817+
/// via inject_orphan_subagents + the convert_display_items fallback.
818+
#[test]
819+
fn skill_forked_agents_have_subagent_desc_populated() {
820+
use crate::parser::chunk::build_chunks;
821+
use crate::parser::session::read_session_with_debug_hooks;
822+
use crate::parser::subagent::{discover_and_link_all, inject_orphan_subagents};
823+
824+
let session_path = concat!(
825+
env!("HOME"),
826+
"/.claude/projects",
827+
"/-Users-yang-liu--dovepaw-workspaces--oncall-analyzer-oa-0104cf01",
828+
"/3f97c7ca-41e5-4a0d-b20e-beea73a63aa1.jsonl"
829+
);
830+
831+
// Skip if the session doesn't exist on this machine.
832+
if !std::path::Path::new(session_path).exists() {
833+
return;
834+
}
835+
836+
let (classified, _, _) = read_session_with_debug_hooks(session_path).unwrap();
837+
let mut chunks = build_chunks(&classified);
838+
let (mut all_procs, color_map) = discover_and_link_all(session_path, &chunks);
839+
inject_orphan_subagents(&mut chunks, &mut all_procs);
840+
841+
let messages = chunks_to_messages(&chunks, &all_procs, &color_map);
842+
843+
// Collect all Subagent items across all messages.
844+
let agent_items: Vec<_> = messages
845+
.iter()
846+
.flat_map(|m| m.items.iter())
847+
.filter(|it| it.item_type == "Subagent")
848+
.collect();
849+
850+
assert!(
851+
!agent_items.is_empty(),
852+
"session must produce at least one Subagent item"
853+
);
854+
855+
let expected_skills = [
856+
"rollbar-reader",
857+
"pagerduty-oncall",
858+
"datadog-analyser",
859+
"slack-explorer",
860+
"cloudflare-traffic-investigator",
861+
"pir",
862+
];
863+
864+
for item in &agent_items {
865+
assert!(
866+
!item.subagent_desc.is_empty(),
867+
"subagent_desc must not be empty (agent_id={})",
868+
item.agent_id
869+
);
870+
assert!(
871+
expected_skills.contains(&item.subagent_desc.as_str()),
872+
"subagent_desc '{}' must be a known skill name (agent_id={})",
873+
item.subagent_desc,
874+
item.agent_id
875+
);
876+
}
877+
}
813878
}

src-tauri/src/parser/entry.rs

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,25 @@ use serde::Deserialize;
22
use serde_json::Value;
33
use std::collections::HashMap;
44

5+
/// Deserializes a JSON string field, treating `null` as the type's default
6+
/// value. Serde's `#[serde(default)]` only applies when the field is absent;
7+
/// this helper also handles the `"field": null` case.
8+
fn null_as_default<'de, D, T>(d: D) -> Result<T, D::Error>
9+
where
10+
D: serde::Deserializer<'de>,
11+
T: Default + Deserialize<'de>,
12+
{
13+
Ok(Option::<T>::deserialize(d)?.unwrap_or_default())
14+
}
15+
516
/// Entry represents a raw JSONL line from a Claude Code session file.
617
#[derive(Debug, Deserialize, Default)]
718
pub struct Entry {
819
#[serde(default, rename = "type")]
920
pub entry_type: String,
1021
#[serde(default)]
1122
pub uuid: String,
12-
#[serde(default, rename = "parentUuid")]
23+
#[serde(default, rename = "parentUuid", deserialize_with = "null_as_default")]
1324
pub parent_uuid: String,
1425
#[serde(default)]
1526
pub timestamp: String,
@@ -207,4 +218,22 @@ mod tests {
207218
};
208219
assert!(e.tool_use_result_map().is_none());
209220
}
221+
222+
#[test]
223+
fn parse_entry_handles_null_parent_uuid() {
224+
// Subagent JSONL files write parentUuid: null for the first entry.
225+
// parse_entry must succeed and treat null as an empty string.
226+
let line = json!({
227+
"type": "user",
228+
"uuid": "e65f5102-fdbe-424d-814f-a04e1ab466c6",
229+
"parentUuid": null,
230+
"isSidechain": true,
231+
"timestamp": "2026-04-12T21:18:39.485Z",
232+
"message": {"role": "user", "content": "Base directory for this skill: /skills/test"}
233+
});
234+
let bytes = serde_json::to_vec(&line).unwrap();
235+
let entry = parse_entry(&bytes).expect("must parse despite null parentUuid");
236+
assert_eq!(entry.parent_uuid, "");
237+
assert_eq!(entry.entry_type, "user");
238+
}
210239
}

0 commit comments

Comments
 (0)