diff --git a/bt-daemon/src/translate/cursor/mod.rs b/bt-daemon/src/translate/cursor/mod.rs index dbdc53bf..18b59369 100644 --- a/bt-daemon/src/translate/cursor/mod.rs +++ b/bt-daemon/src/translate/cursor/mod.rs @@ -1138,26 +1138,21 @@ impl CursorTranslator { } self.ensure_turn(e.ts_ms, None, None, "hook_order", ops); let spawn = hook.tool_call_id.as_deref(); - let Some((parent, turn)) = spawn - .and_then(|s| self.tools.get(s).or_else(|| self.completed.get(s))) - .map(|t| (t.row.span_id.clone(), t.turn.clone())) - .or_else(|| { - self.turn - .as_ref() - .map(|turn| (turn.id.clone(), turn.id.clone())) - }) + let Some(turn) = spawn + .and_then(|call| self.tools.get(call).or_else(|| self.completed.get(call))) + .map(|tool| tool.turn.clone()) + .or_else(|| self.turn.as_ref().map(|turn| turn.id.clone())) else { return; }; let mut row = self.row( ids::span_id(&self.namespace, &format!("subagent:{id}")), - parent, + turn, "Cursor subagent", SpanType::Task, ); - row.turn_span_id = Some(turn); row.start_ms = Some(e.ts_ms); - row.input = Some(json!(hook.task)); + row.input = Some(user_prompt(&hook.task)); row.metadata = Some( json!({"subagent_id":id,"spawning_tool_call_id":spawn,"model":hook.subagent_model,"subagent_type":hook.subagent_type,"recursive_activity_verified":false}), ); @@ -1182,9 +1177,10 @@ impl CursorTranslator { return None; } let mut matching_ids = self.subagents.iter().filter_map(|(id, row)| { - let task_matches = hook.task.as_ref().is_none_or(|task| { - row.input.as_ref().and_then(Value::as_str) == Some(task.as_str()) - }); + let task_matches = hook + .task + .as_ref() + .is_none_or(|task| row.input.as_ref() == Some(&user_prompt(task))); let type_matches = hook.subagent_type.as_ref().is_none_or(|kind| { row.metadata .as_ref() @@ -1248,7 +1244,7 @@ impl CursorTranslator { let duration = hook.duration_ms.and_then(|ms| i64::try_from(ms).ok()); row.start_ms = Some(duration.map_or(e.ts_ms, |duration| e.ts_ms.saturating_sub(duration))); row.end_ms = Some(e.ts_ms); - row.input = hook.task.as_ref().map(|task| json!(task)); + row.input = hook.task.as_ref().map(|task| user_prompt(task)); row.output = hook.summary.as_ref().map(|summary| json!(summary)); row.metadata = Some(json!({ "subagent_id": hook.subagent_id, @@ -1600,6 +1596,10 @@ impl AgentTranslator for CursorTranslator { Ok(ops) } } +fn user_prompt(text: &str) -> Value { + json!([{ "role": "user", "content": text }]) +} + fn assistant_message(content: &[Value]) -> Value { let mut text = String::new(); let mut reasoning = Vec::new(); diff --git a/bt-daemon/tests/cursor_translator.rs b/bt-daemon/tests/cursor_translator.rs index ec65e380..5ac818ef 100644 --- a/bt-daemon/tests/cursor_translator.rs +++ b/bt-daemon/tests/cursor_translator.rs @@ -1069,7 +1069,7 @@ fn synthetic_usage_without_verified_prompt_generation_stays_unavailable() { } #[test] -fn synthetic_subagents_parent_to_spawning_tool_and_unmatched_stop_stays_incomplete() { +fn synthetic_subagents_stay_on_the_spawning_turn() { let mut h = Harness::new("test-session"); h.handle(&event( "beforeSubmitPrompt", @@ -1091,95 +1091,87 @@ fn synthetic_subagents_parent_to_spawning_tool_and_unmatched_stop_stays_incomple 130, json!({"subagent_id":"child-b","tool_call_id":"spawn","subagent_type":"explore","subagent_model":"auto","task":"explore"}), )); + h.handle(&event( + "subagentStart", + 135, + json!({"subagent_id":"child-c","tool_call_id":"spawn","subagent_type":"explore","subagent_model":"auto","task":"summarize"}), + )); h.handle(&event( "subagentStop", 140, json!({"subagent_id":"child-a","subagent_type":"explore","status":"completed","summary":"found the issue"}), )); + h.handle(&event( + "subagentStop", + 145, + json!({"task":"summarize","subagent_type":"explore","status":"completed","summary":"summary done"}), + )); + h.handle(&event( + "subagentStop", + 150, + json!({ + "subagent_id":"child-terminal-only", + "status":"completed", + "summary":"finished work", + "subagent_type":"explore" + }), + )); h.handle(&event( "sessionEnd", 160, json!({"reason":"completed","final_status":"completed"}), )); h.finish(); + let turns = h.turns(); + let turn_id = turns[0].span_id.clone(); + let root_id = turns[0].root_span_id.clone(); + let rows = h.rows(); let children: Vec<_> = h .inserted(SpanType::Task) .into_iter() .filter(|r| r.name == "Cursor subagent") .collect(); - assert_eq!(children.len(), 2); - let rows = h.rows(); - let spawn_tool_id = h - .inserted(SpanType::Tool) - .into_iter() - .find(|row| row.name == "Task") - .unwrap() - .span_id - .clone(); + assert_eq!(children.len(), 4); for child in children { - assert_eq!(child.parent_span_ids, vec![spawn_tool_id.clone()]); - assert_eq!(child.turn_span_id, Some(h.turns()[0].span_id.clone())); - assert_eq!( - child.metadata.as_ref().unwrap()["spawning_tool_call_id"], - "spawn" - ); - assert_eq!( - child.metadata.as_ref().unwrap()["recursive_activity_verified"], - false - ); + assert_eq!(child.root_span_id, root_id); + assert_eq!(child.parent_span_ids, vec![turn_id.clone()]); + assert_eq!(child.metadata.as_ref().unwrap()["turn_span_id"], turn_id); let row = &rows[&child.span_id]; - if child.start_ms == Some(120) { - assert_eq!(row["end_ms"], 140); - assert_eq!(child.input, Some(json!("inspect the code"))); - assert_eq!(row["output"], "found the issue"); - assert_eq!(row["metadata"]["status"], "completed"); - } else { - assert!(row.get("output").is_none()); - assert_eq!(row["metadata"]["status"], "incomplete"); + match row["metadata"]["subagent_id"].as_str() { + Some("child-a") => { + assert_eq!(row["end_ms"], 140); + assert_eq!( + child.input, + Some(json!([{ "role": "user", "content": "inspect the code" }])) + ); + assert_eq!(row["output"], "found the issue"); + assert_eq!(row["metadata"]["status"], "completed"); + assert_eq!(row["metadata"]["spawning_tool_call_id"], "spawn"); + assert_eq!(row["metadata"]["recursive_activity_verified"], false); + } + Some("child-b") => { + assert!(row.get("output").is_none()); + assert_eq!(row["metadata"]["status"], "incomplete"); + } + Some("child-c") => { + assert_eq!(row["output"], "summary done"); + assert_eq!(row["metadata"]["status"], "completed"); + assert!(row["metadata"].get("result_completeness").is_none()); + } + Some("child-terminal-only") => { + assert_eq!(child.end_ms, Some(150)); + assert_eq!(row["output"], "finished work"); + assert_eq!(row["metadata"]["status"], "completed"); + assert_eq!(row["metadata"]["start_time_estimated"], true); + assert_eq!( + row["metadata"]["result_completeness"], + "terminal_observation_only" + ); + assert!(row["metadata"].get("model").is_none()); + } + other => panic!("unexpected subagent {other:?}"), } } - assert_eq!( - rows[&h.inserted(SpanType::Tool)[0].span_id]["metadata"]["status"], - "incomplete" - ); -} - -#[test] -fn terminal_only_subagent_stop_synthesizes_estimated_span() { - let mut h = Harness::new("test-session"); - h.handle(&event( - "beforeSubmitPrompt", - 100, - json!({"generation_id":"t","prompt":"delegate"}), - )); - h.handle(&event( - "subagentStop", - 140, - json!({ - "subagent_id":"child-terminal-only", - "status":"completed", - "summary":"finished work", - "subagent_type":"explore" - }), - )); - h.finish(); - - let child = h - .inserted(SpanType::Task) - .into_iter() - .find(|row| row.name == "Cursor subagent") - .unwrap(); - assert_eq!(child.start_ms, Some(140)); - assert_eq!(child.end_ms, Some(140)); - assert_eq!(child.output, Some(json!("finished work"))); - let metadata = child.metadata.as_ref().unwrap(); - assert_eq!(metadata["start_time_estimated"], true); - assert_eq!(metadata["result_completeness"], "terminal_observation_only"); - assert_eq!(metadata["status"], "completed"); - assert_eq!(metadata["subagent_type"], "explore"); - // subagentStop does not report the subagent's model. - assert!(metadata.get("model").is_none()); - assert_eq!(child.parent_span_ids, vec![h.turns()[0].span_id.clone()]); } #[test] fn synthetic_compaction_is_observation_only_and_clears_unavailable_context() {