From 47ce336773c2dda481f72ee1ce4d6e737769f54e Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Mon, 26 Oct 2026 20:39:17 -0400 Subject: [PATCH] fix(cursor): parent subagent spans to the spawning turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > 🤖 **This text was AI-generated.** Cursor subagent spans were children of the spawning tool, and the task was stored as a raw string. The instrumentation spec's turn tree puts the subagent on the same trace as the user turn that spawned it, with `metadata.turn_span_id` set to that turn. A raw string also broke stop correlation: a stop that omitted `subagent_id` compared the task to `input` as a string, so it could not find the open child. Subagent spans now share the session root and hang directly off that user turn. The spawning tool call id stays in metadata. The task is a user message, and a stop without an id matches that same message. ```text session └── turn ├── tool Task └── task Cursor subagent input: [{role: "user", content: ""}] ``` Stops that do not match an open start still become a terminal observation on the turn. Inner model and tool work is not attached under the subagent, because Cursor does not provide a verified link for that activity. Co-authored-by: Cursor --- bt-daemon/src/translate/cursor/mod.rs | 30 +++--- bt-daemon/tests/cursor_translator.rs | 132 ++++++++++++-------------- 2 files changed, 77 insertions(+), 85 deletions(-) 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() {