From 9efa100a4c39b262c045f407c3b6bd4c553c4b98 Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Wed, 21 Oct 2026 01:07:09 +0800 Subject: [PATCH 1/2] Use Claude subagent handback reports as output --- bt-daemon/src/transcript_import/claude.rs | 27 +++++- bt-daemon/src/translate/claude.rs | 17 +++- bt-daemon/tests/claude_translator.rs | 109 ++++++++++++++++++++++ bt-daemon/tests/replay.rs | 24 ++++- 4 files changed, 174 insertions(+), 3 deletions(-) diff --git a/bt-daemon/src/transcript_import/claude.rs b/bt-daemon/src/transcript_import/claude.rs index 09916076..f7eab01e 100644 --- a/bt-daemon/src/transcript_import/claude.rs +++ b/bt-daemon/src/transcript_import/claude.rs @@ -256,7 +256,10 @@ pub(super) fn envelopes( "agent_transcript_path": subagent.path }); if !is_start { - payload["last_assistant_message"] = json!(subagent.last_assistant_message); + payload["last_assistant_message"] = json!(subagent + .handback_message + .clone() + .or_else(|| subagent.last_assistant_message.clone())); } events.push(import_envelope( source_version.clone(), @@ -315,6 +318,27 @@ struct Subagent { start_ms: i64, end_ms: i64, last_assistant_message: Option, + handback_message: Option, +} + +fn last_handback_message(records: &[Value]) -> Option { + records.iter().rev().find_map(|record| { + if record.get("type").and_then(Value::as_str) != Some("assistant") { + return None; + } + record + .pointer("/message/content") + .and_then(Value::as_array) + .into_iter() + .flatten() + .rev() + .find(|block| { + block.get("type").and_then(Value::as_str) == Some("tool_use") + && block.get("name").and_then(Value::as_str) == Some("SubagentHandback") + }) + .and_then(|block| block.pointer("/input/message")) + .cloned() + }) } fn subagents(path: &Path, records: &[Value]) -> anyhow::Result> { @@ -461,6 +485,7 @@ fn subagents(path: &Path, records: &[Value]) -> anyhow::Result> { start_ms, end_ms, last_assistant_message: last_assistant_text(&subagent_records), + handback_message: last_handback_message(&subagent_records), }) }) .collect::>(); diff --git a/bt-daemon/src/translate/claude.rs b/bt-daemon/src/translate/claude.rs index 5b58f8a5..c5cccb66 100644 --- a/bt-daemon/src/translate/claude.rs +++ b/bt-daemon/src/translate/claude.rs @@ -282,6 +282,7 @@ struct ClaudeTranslator { emitted_tools: RecentSet, pending_tools: HashMap, subagents: HashMap, + subagent_handbacks: HashMap, pending_skills: Vec, pending_emission: Option, claude_version: Option, @@ -331,6 +332,7 @@ impl ClaudeTranslator { emitted_tools: RecentSet::default(), pending_tools: HashMap::new(), subagents: HashMap::new(), + subagent_handbacks: HashMap::new(), pending_skills: Vec::new(), pending_emission: None, claude_version: None, @@ -781,6 +783,15 @@ impl ClaudeTranslator { let error = (approval != ToolApproval::Denied) .then(|| forced_error.or_else(|| tool_error(&event.payload))) .flatten(); + if approval == ToolApproval::Approved && error.is_none() && tool_name == "SubagentHandback" + { + if let (Some(agent_id), Some(message)) = ( + string_field(&event.payload, "agent_id"), + raw_tool_input(&event.payload).and_then(|input| input.get("message")), + ) { + self.subagent_handbacks.insert(agent_id, message.clone()); + } + } let mut metadata = tool_metadata(event, &tool_name, &call_id, Some(approval), &input); if let Some(pending) = self.pending_tools.remove(&call_id) { // A PreToolUse hook may rewrite the call via `updatedInput`, so the @@ -881,12 +892,16 @@ impl ClaudeTranslator { "Subagent ended before tool completion", ops, ); + let output = self + .subagent_handbacks + .remove(&agent_id) + .or_else(|| event.payload.get("last_assistant_message").cloned()); ops.push(SpanOp::Merge(SpanRow { span_id: parent.clone(), root_span_id: self.root_span_id.clone(), parent_span_ids, end_ms: Some(event.ts_ms), - output: event.payload.get("last_assistant_message").cloned(), + output, ..Default::default() })); self.subagents.remove(&agent_id); diff --git a/bt-daemon/tests/claude_translator.rs b/bt-daemon/tests/claude_translator.rs index 82c1f6e2..2e9ae95f 100644 --- a/bt-daemon/tests/claude_translator.rs +++ b/bt-daemon/tests/claude_translator.rs @@ -700,6 +700,115 @@ fn claude_subagent_stops_preserve_lifecycle_and_missing_start_recovery() { assert_eq!(tool.end_ms, Some(50)); } +#[test] +fn claude_subagent_handback_report_is_the_span_output() { + let registry = Registry::default_agents(); + let mut translator = registry.create("claude-code", "subagent-handback"); + let ctx = SessionCtx { + session_id: "subagent-handback".into(), + config: None, + }; + let event = |name, ts_ms, payload| claude_event(&ctx.session_id, name, ts_ms, payload); + let report = "The implementation is complete; these are the findings."; + let mut ops = Vec::new(); + for envelope in [ + event("UserPromptSubmit", 10, json!({"prompt": "delegate"})), + event( + "SubagentStart", + 20, + json!({"agent_id": "worker", "agent_type": "reviewer"}), + ), + event( + "PreToolUse", + 30, + json!({ + "agent_id": "worker", "tool_name": "SubagentHandback", + "tool_use_id": "handback-1", "tool_input": {"message": report} + }), + ), + event( + "PostToolUse", + 40, + json!({ + "agent_id": "worker", "tool_name": "SubagentHandback", + "tool_use_id": "handback-1", "tool_input": {"message": report}, + "tool_response": {"message": "Report delivered"} + }), + ), + event( + "SubagentStop", + 50, + json!({ + "agent_id": "worker", "agent_type": "reviewer", + "last_assistant_message": "Handback complete." + }), + ), + event( + "SubagentStart", + 60, + json!({"agent_id": "ordinary", "agent_type": "worker"}), + ), + event( + "SubagentStop", + 70, + json!({ + "agent_id": "ordinary", "agent_type": "worker", + "last_assistant_message": "Ordinary subagent result." + }), + ), + ] { + ops.extend(translator.handle(&envelope, &ctx).unwrap()); + } + let rows = reduce(ops); + let handback_agent = rows + .values() + .find(|row| { + row.name == "subagent: reviewer" + && row + .metadata + .as_ref() + .and_then(|metadata| metadata.get("agent_id")) + == Some(&json!("worker")) + }) + .unwrap(); + assert_eq!(handback_agent.output, Some(json!(report))); + let handback_tool = rows + .values() + .find(|row| { + row.span_type == SpanType::Tool + && row + .metadata + .as_ref() + .and_then(|metadata| metadata.get("tool_call_id")) + == Some(&json!("handback-1")) + }) + .unwrap(); + assert_eq!( + handback_tool.parent_span_ids, + vec![handback_agent.span_id.clone()] + ); + assert_eq!( + handback_tool.output.as_ref().unwrap()["message"], + json!("Report delivered") + ); + + let ordinary_agent = rows + .values() + .find(|row| { + row.name == "subagent: worker" + && row + .metadata + .as_ref() + .and_then(|metadata| metadata.get("agent_id")) + == Some(&json!("ordinary")) + }) + .unwrap(); + assert_eq!( + ordinary_agent.output, + Some(json!("Ordinary subagent result.")) + ); +} + #[test] fn claude_subagent_fixture_builds_nested_subagent_llms() { let rows = reduce(replay("subagent-compact")); diff --git a/bt-daemon/tests/replay.rs b/bt-daemon/tests/replay.rs index 317ff27a..6e6f578d 100644 --- a/bt-daemon/tests/replay.rs +++ b/bt-daemon/tests/replay.rs @@ -360,7 +360,9 @@ async fn importing_claude_parent_also_imports_its_subagent_into_the_same_trace() write_jsonl( &subagent, &[ - json!({"type":"assistant","timestamp":"2026-01-01T00:00:04Z","sessionId":"claude-parent","message":{"id":"child-message","model":"claude-test","content":[{"type":"text","text":"reviewed"}],"usage":{"input_tokens":2,"output_tokens":1}}}), + json!({"type":"assistant","timestamp":"2026-01-01T00:00:03Z","sessionId":"claude-parent","message":{"id":"child-handback","model":"claude-test","content":[{"type":"tool_use","id":"handback-call","name":"SubagentHandback","input":{"message":"The review report delivered to the caller."}}],"usage":{"input_tokens":2,"output_tokens":1}}}), + json!({"type":"user","timestamp":"2026-01-01T00:00:04Z","sessionId":"claude-parent","message":{"content":[{"type":"tool_result","tool_use_id":"handback-call","content":"Report delivered"}]}}), + json!({"type":"assistant","timestamp":"2026-01-01T00:00:04.500Z","sessionId":"claude-parent","message":{"id":"child-message","model":"claude-test","content":[{"type":"text","text":"Handback complete."}],"usage":{"input_tokens":2,"output_tokens":1}}}), ], ); @@ -378,6 +380,11 @@ async fn importing_claude_parent_also_imports_its_subagent_into_the_same_trace() let rows = rows(&output.join("claude-parent.ndjson")); assert_eq!(inserted(&rows, "task"), 3, "session, turn, and subagent"); assert_eq!(inserted(&rows, "llm"), 3, "parent and child model calls"); + assert_eq!( + inserted(&rows, "tool"), + 2, + "agent and handback remain tool spans" + ); let inserts = rows .iter() .filter_map(|op| op.get("Insert")) @@ -399,6 +406,21 @@ async fn importing_claude_parent_also_imports_its_subagent_into_the_same_trace() .unwrap(); assert_eq!(child["root_span_id"], root["span_id"]); assert_eq!(child["parent_span_ids"][0], turn["span_id"]); + let child_merge = rows + .iter() + .find(|op| { + op.pointer("/Merge/span_id").and_then(Value::as_str) == child["span_id"].as_str() + }) + .unwrap(); + assert_eq!( + child_merge.pointer("/Merge/output").and_then(Value::as_str), + Some("The review report delivered to the caller.") + ); + let handback_tool = inserts + .iter() + .find(|row| row.get("name").and_then(Value::as_str) == Some("SubagentHandback")) + .unwrap(); + assert_eq!(handback_tool["parent_span_ids"][0], child["span_id"]); } #[tokio::test] From 94e2ce726612d4bdfd132a4fb7e85e88ed4b1b36 Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Wed, 21 Oct 2026 01:25:18 +0800 Subject: [PATCH 2/2] Recover successful Claude handback output --- bt-daemon/src/transcript_import/claude.rs | 24 ++------- bt-daemon/src/translate/claude.rs | 60 ++++++++++++++++++++- bt-daemon/src/translate/mod.rs | 1 + bt-daemon/tests/claude_translator.rs | 65 +++++++++++++++++++++++ 4 files changed, 128 insertions(+), 22 deletions(-) diff --git a/bt-daemon/src/transcript_import/claude.rs b/bt-daemon/src/transcript_import/claude.rs index f7eab01e..38522c5c 100644 --- a/bt-daemon/src/transcript_import/claude.rs +++ b/bt-daemon/src/transcript_import/claude.rs @@ -321,26 +321,6 @@ struct Subagent { handback_message: Option, } -fn last_handback_message(records: &[Value]) -> Option { - records.iter().rev().find_map(|record| { - if record.get("type").and_then(Value::as_str) != Some("assistant") { - return None; - } - record - .pointer("/message/content") - .and_then(Value::as_array) - .into_iter() - .flatten() - .rev() - .find(|block| { - block.get("type").and_then(Value::as_str) == Some("tool_use") - && block.get("name").and_then(Value::as_str) == Some("SubagentHandback") - }) - .and_then(|block| block.pointer("/input/message")) - .cloned() - }) -} - fn subagents(path: &Path, records: &[Value]) -> anyhow::Result> { let Some(stem) = path.file_stem() else { return Ok(Vec::new()); @@ -485,7 +465,9 @@ fn subagents(path: &Path, records: &[Value]) -> anyhow::Result> { start_ms, end_ms, last_assistant_message: last_assistant_text(&subagent_records), - handback_message: last_handback_message(&subagent_records), + handback_message: crate::translate::successful_subagent_handback_message( + &subagent_records, + ), }) }) .collect::>(); diff --git a/bt-daemon/src/translate/claude.rs b/bt-daemon/src/translate/claude.rs index c5cccb66..a84ff2bf 100644 --- a/bt-daemon/src/translate/claude.rs +++ b/bt-daemon/src/translate/claude.rs @@ -870,12 +870,14 @@ impl ClaudeTranslator { if let Some(agent) = self.subagents.get_mut(&agent_id) { agent.transcript_path = path.clone(); } + let mut transcript_handback = None; if let Some(path) = path { let cursor = self.transcripts.entry(path.clone()).or_default(); cursor .buffered .extend(read_event_records(event, &path, &mut cursor.offset)); let records = std::mem::take(&mut cursor.buffered); + transcript_handback = successful_subagent_handback_message(&records); self.queue_transcript( records, PendingHistory::Owned(MessageHistory::default()), @@ -895,6 +897,7 @@ impl ClaudeTranslator { let output = self .subagent_handbacks .remove(&agent_id) + .or(transcript_handback) .or_else(|| event.payload.get("last_assistant_message").cloned()); ops.push(SpanOp::Merge(SpanRow { span_id: parent.clone(), @@ -1747,6 +1750,22 @@ fn parse_transcript(records: &[Value], mut history: MessageHistory) -> ParsedTra } } +/// Returns the most recent SubagentHandback report whose matching transcript +/// result proves that the tool completed successfully. +pub(crate) fn successful_subagent_handback_message(records: &[Value]) -> Option { + parse_transcript(records, MessageHistory::default()) + .tools + .into_iter() + .rev() + .find(|tool| { + tool.tool_name == "SubagentHandback" + && tool.approval == Some(ToolApproval::Approved) + && tool.error.is_none() + && !tool.cancelled + }) + .and_then(|tool| tool.input.get("message").cloned()) +} + fn continuation_origin(payload: &Value) -> Option<(&'static str, &str)> { if let Some(origin) = payload.get("origin") { if origin.get("kind").and_then(Value::as_str) == Some("peer") @@ -2615,7 +2634,7 @@ fn turn_input_kind(payload: &Value) -> Option<&'static str> { mod tests { use super::{ decode, parse_rfc3339_timestamp, parse_timestamp_ms, read_snapshot_bounded, - TranscriptEnvelope, + successful_subagent_handback_message, TranscriptEnvelope, }; #[test] @@ -2681,4 +2700,43 @@ mod tests { ); assert!(read_snapshot_bounded(&contents, &mut offset, None, None).is_empty()); } + + #[test] + fn handback_requires_a_successful_matching_tool_result() { + let assistant = serde_json::json!({ + "type": "assistant", + "message": {"id": "request", "content": [{ + "type": "tool_use", "id": "handback", "name": "SubagentHandback", + "input": {"message": "report"} + }]} + }); + let successful = serde_json::json!({ + "type": "user", + "message": {"content": [{ + "type": "tool_result", "tool_use_id": "handback", "content": "Delivered" + }]} + }); + let failed = serde_json::json!({ + "type": "user", + "message": {"content": [{ + "type": "tool_result", "tool_use_id": "handback", "content": "Failed", + "is_error": true + }]} + }); + + assert_eq!( + successful_subagent_handback_message(&[assistant.clone(), successful]), + Some(serde_json::json!("report")) + ); + assert_eq!( + successful_subagent_handback_message(&[assistant.clone(), failed]), + None, + "a failed handback proposal is not output delivered to the caller" + ); + assert_eq!( + successful_subagent_handback_message(&[assistant]), + None, + "an incomplete handback proposal is not output delivered to the caller" + ); + } } diff --git a/bt-daemon/src/translate/mod.rs b/bt-daemon/src/translate/mod.rs index 52327dea..412b2717 100644 --- a/bt-daemon/src/translate/mod.rs +++ b/bt-daemon/src/translate/mod.rs @@ -17,6 +17,7 @@ mod git; mod grok; mod opencode; mod pi; +pub(crate) use claude::successful_subagent_handback_message; pub(crate) use pi::request_config as pi_request_config; mod recent; mod tool; diff --git a/bt-daemon/tests/claude_translator.rs b/bt-daemon/tests/claude_translator.rs index 2e9ae95f..af5465e2 100644 --- a/bt-daemon/tests/claude_translator.rs +++ b/bt-daemon/tests/claude_translator.rs @@ -809,6 +809,71 @@ fn claude_subagent_handback_report_is_the_span_output() { ); } +#[test] +fn claude_subagent_stop_recovers_handback_from_transcript_without_post_tool_hook() { + let tmp = tempfile::tempdir().unwrap(); + let transcript = tmp.path().join("agent-worker.jsonl"); + let report = "Recovered report delivered to the caller."; + let records = [ + json!({"type":"assistant","timestamp":"1970-01-01T00:00:03Z","message":{"id":"handback","content":[{"type":"tool_use","id":"handback-1","name":"SubagentHandback","input":{"message":report}}]}}), + json!({"type":"user","timestamp":"1970-01-01T00:00:04Z","message":{"content":[{"type":"tool_result","tool_use_id":"handback-1","content":"Delivered"}]}}), + json!({"type":"assistant","timestamp":"1970-01-01T00:00:04.500Z","message":{"id":"closing","content":[{"type":"text","text":"Handback complete."}]}}), + ]; + std::fs::write( + &transcript, + records + .iter() + .map(Value::to_string) + .collect::>() + .join("\n") + + "\n", + ) + .unwrap(); + + let registry = Registry::default_agents(); + let mut translator = registry.create("claude-code", "subagent-transcript-recovery"); + let ctx = SessionCtx { + session_id: "subagent-transcript-recovery".into(), + config: None, + }; + let path = transcript.to_str().unwrap(); + let events = [ + claude_event( + &ctx.session_id, + "UserPromptSubmit", + 1_000, + json!({"prompt":"delegate"}), + ), + claude_event( + &ctx.session_id, + "SubagentStart", + 2_000, + json!({"agent_id":"worker","agent_type":"reviewer","agent_transcript_path":path}), + ), + claude_event( + &ctx.session_id, + "SubagentStop", + 5_000, + json!({"agent_id":"worker","agent_type":"reviewer","agent_transcript_path":path,"last_assistant_message":"Handback complete."}), + ), + ]; + let ops = events + .iter() + .flat_map(|event| translator.handle(event, &ctx).unwrap()) + .collect(); + let rows = reduce(ops); + let agent = rows + .values() + .find(|row| row.name == "subagent: reviewer") + .unwrap(); + assert_eq!(agent.output, Some(json!(report))); + assert!(rows.values().any(|row| { + row.span_type == SpanType::Tool + && row.name == "SubagentHandback" + && row.parent_span_ids.contains(&agent.span_id) + })); +} + #[test] fn claude_subagent_fixture_builds_nested_subagent_llms() { let rows = reduce(replay("subagent-compact"));