feat(ui): add dedicated Agent subagent rendering with nested tool call display
Render Agent/Explore/Plan tool calls in a visually distinct collapsible
container with colored left border, replacing the generic tool card. Parse
subagent JSONL transcripts from {sessionId}/subagents/ to extract and
display the actual tool calls (Bash, Read, Grep, etc.) the subagent
executed, reusing the existing ToolCallPart for consistent appearance.
- Add AgentToolCallPart component with collapsible body, prompt section,
execution stats, and nested tool call list via render prop injection
- Add AgentExecutionStats and AgentToolCall types (Rust + TypeScript)
- Parse toolUseResult.agentId to locate and read subagent JSONL files
- Validate agentId against path traversal before filesystem access
- Pass agentStats through adapter for both ID-matched and positional
tool result pairing
- Strip agentStats in nested render to prevent recursive Agent expansion
- Add i18n keys for agent UI labels across all 10 languages
This commit is contained in:
@@ -10,6 +10,48 @@ pub enum MessageRole {
|
||||
Tool,
|
||||
}
|
||||
|
||||
/// A single tool call record from a subagent's execution transcript.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentToolCall {
|
||||
pub tool_name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input_preview: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_preview: Option<String>,
|
||||
pub is_error: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentExecutionStats {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub agent_type: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub status: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub total_duration_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub total_tokens: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub total_tool_use_count: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub read_count: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub search_count: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bash_count: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub edit_file_count: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lines_added: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lines_removed: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub other_tool_count: Option<u32>,
|
||||
/// Tool calls extracted from the subagent's own JSONL transcript.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub tool_calls: Vec<AgentToolCall>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ContentBlock {
|
||||
@@ -31,6 +73,8 @@ pub enum ContentBlock {
|
||||
tool_use_id: Option<String>,
|
||||
output_preview: Option<String>,
|
||||
is_error: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
agent_stats: Option<AgentExecutionStats>,
|
||||
},
|
||||
Thinking {
|
||||
text: String,
|
||||
|
||||
@@ -15,7 +15,10 @@ pub use conversation::{
|
||||
SidebarData,
|
||||
};
|
||||
pub use folder::{FolderCommandInfo, FolderDetail, FolderHistoryEntry, OpenedConversation};
|
||||
pub use message::{ContentBlock, MessageRole, MessageTurn, TurnRole, TurnUsage, UnifiedMessage};
|
||||
pub use message::{
|
||||
AgentExecutionStats, AgentToolCall, ContentBlock, MessageRole, MessageTurn, TurnRole,
|
||||
TurnUsage, UnifiedMessage,
|
||||
};
|
||||
pub use system::{
|
||||
GitCredentials, GitDetectResult, GitHubAccountsSettings, GitHubTokenValidation, GitSettings,
|
||||
SystemLanguageSettings, SystemProxySettings,
|
||||
|
||||
@@ -558,7 +558,7 @@ impl ClaudeParser {
|
||||
MessageRole::User
|
||||
};
|
||||
|
||||
// Check toolUseResult.structuredPatch for real line numbers
|
||||
// Check toolUseResult for structured patch and agent execution stats
|
||||
if let Some(tur) = value.get("toolUseResult") {
|
||||
if let Some(sp) = tur.get("structuredPatch") {
|
||||
let fp = tur
|
||||
@@ -581,6 +581,41 @@ impl ClaudeParser {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract agent execution stats from toolUseResult
|
||||
if tur.get("agentType").is_some() {
|
||||
let mut stats = extract_agent_execution_stats(tur);
|
||||
// Load tool calls from subagent's own JSONL transcript
|
||||
if let Some(agent_id) =
|
||||
tur.get("agentId").and_then(|v| v.as_str())
|
||||
{
|
||||
// Reject path traversal: agentId must be alphanumeric
|
||||
if !agent_id.is_empty()
|
||||
&& !agent_id.contains('/')
|
||||
&& !agent_id.contains('\\')
|
||||
&& !agent_id.contains("..")
|
||||
{
|
||||
let subagent_dir =
|
||||
path.with_extension("").join("subagents");
|
||||
let subagent_path = subagent_dir
|
||||
.join(format!("agent-{}.jsonl", agent_id));
|
||||
if subagent_path.exists() {
|
||||
stats.tool_calls =
|
||||
parse_subagent_tool_calls(&subagent_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
for block in content.iter_mut() {
|
||||
if let ContentBlock::ToolResult {
|
||||
ref mut agent_stats,
|
||||
..
|
||||
} = block
|
||||
{
|
||||
*agent_stats = Some(stats);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
messages.push(UnifiedMessage {
|
||||
@@ -776,6 +811,7 @@ impl ClaudeParser {
|
||||
tool_use_id: matching_id,
|
||||
output_preview,
|
||||
is_error,
|
||||
agent_stats: None,
|
||||
});
|
||||
} else {
|
||||
messages.push(UnifiedMessage {
|
||||
@@ -785,6 +821,7 @@ impl ClaudeParser {
|
||||
tool_use_id: matching_id,
|
||||
output_preview,
|
||||
is_error,
|
||||
agent_stats: None,
|
||||
}],
|
||||
timestamp: parse_timestamp(&value).unwrap_or_else(Utc::now),
|
||||
usage: None,
|
||||
@@ -912,6 +949,7 @@ fn extract_user_content(value: &serde_json::Value) -> Vec<ContentBlock> {
|
||||
tool_use_id,
|
||||
output_preview: output,
|
||||
is_error,
|
||||
agent_stats: None,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
@@ -1053,6 +1091,161 @@ fn extract_usage(value: &serde_json::Value) -> Option<TurnUsage> {
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_agent_execution_stats(tur: &serde_json::Value) -> AgentExecutionStats {
|
||||
let tool_stats = tur.get("toolStats");
|
||||
AgentExecutionStats {
|
||||
agent_type: tur
|
||||
.get("agentType")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string()),
|
||||
status: tur
|
||||
.get("status")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string()),
|
||||
total_duration_ms: tur.get("totalDurationMs").and_then(|v| v.as_u64()),
|
||||
total_tokens: tur.get("totalTokens").and_then(|v| v.as_u64()),
|
||||
total_tool_use_count: tur
|
||||
.get("totalToolUseCount")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v as u32),
|
||||
read_count: tool_stats
|
||||
.and_then(|s| s.get("readCount"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v as u32),
|
||||
search_count: tool_stats
|
||||
.and_then(|s| s.get("searchCount"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v as u32),
|
||||
bash_count: tool_stats
|
||||
.and_then(|s| s.get("bashCount"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v as u32),
|
||||
edit_file_count: tool_stats
|
||||
.and_then(|s| s.get("editFileCount"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v as u32),
|
||||
lines_added: tool_stats
|
||||
.and_then(|s| s.get("linesAdded"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v as u32),
|
||||
lines_removed: tool_stats
|
||||
.and_then(|s| s.get("linesRemoved"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v as u32),
|
||||
other_tool_count: tool_stats
|
||||
.and_then(|s| s.get("otherToolCount"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v as u32),
|
||||
tool_calls: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a subagent's JSONL transcript and extract its tool calls.
|
||||
///
|
||||
/// The subagent JSONL has the same format as the main session:
|
||||
/// assistant messages with tool_use blocks, followed by user messages
|
||||
/// with tool_result blocks. We pair them by tool_use_id and produce
|
||||
/// a compact list of `AgentToolCall` records.
|
||||
fn parse_subagent_tool_calls(path: &PathBuf) -> Vec<AgentToolCall> {
|
||||
let file = match fs::File::open(path) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
let reader = BufReader::new(file);
|
||||
|
||||
// Collect tool_use entries and build a result map
|
||||
let mut calls: Vec<(String, String, Option<String>)> = Vec::new(); // (id, name, input)
|
||||
let mut results: std::collections::HashMap<String, (Option<String>, bool)> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = match line {
|
||||
Ok(l) => l,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let value: serde_json::Value = match serde_json::from_str(&line) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let msg_type = value.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
|
||||
if msg_type == "assistant" {
|
||||
if let Some(content) = value
|
||||
.get("message")
|
||||
.and_then(|m| m.get("content"))
|
||||
.and_then(|c| c.as_array())
|
||||
{
|
||||
for item in content {
|
||||
let block_type = item.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
if block_type == "tool_use" || block_type == "server_tool_use" {
|
||||
let id = item
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let name = item
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
let input = item.get("input").map(|v| {
|
||||
truncate_str(&v.to_string(), 500)
|
||||
});
|
||||
if !id.is_empty() {
|
||||
calls.push((id, name, input));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if msg_type == "user" {
|
||||
if let Some(content) = value
|
||||
.get("message")
|
||||
.and_then(|m| m.get("content"))
|
||||
.and_then(|c| c.as_array())
|
||||
{
|
||||
for item in content {
|
||||
let block_type = item.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
if block_type == "tool_result" || block_type == "server_tool_result" {
|
||||
let id = item
|
||||
.get("tool_use_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let is_error = item
|
||||
.get("is_error")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let output = extract_tool_result_text(item)
|
||||
.map(|s| truncate_str(&s, 500));
|
||||
if !id.is_empty() {
|
||||
results.insert(id, (output, is_error));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
calls
|
||||
.into_iter()
|
||||
.map(|(id, name, input)| {
|
||||
let (output, is_error) = results
|
||||
.remove(&id)
|
||||
.unwrap_or((None, false));
|
||||
AgentToolCall {
|
||||
tool_name: name,
|
||||
input_preview: input,
|
||||
output_preview: output,
|
||||
is_error,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn extract_tool_result_text(item: &serde_json::Value) -> Option<String> {
|
||||
let content = item.get("content")?;
|
||||
if let Some(text) = content.as_str() {
|
||||
|
||||
@@ -370,6 +370,7 @@ fn parse_user_message_parts(content: &serde_json::Value) -> UserMessageParts {
|
||||
tool_use_id: None,
|
||||
output_preview: Some(truncate_str(&output, 2000)),
|
||||
is_error,
|
||||
agent_stats: None,
|
||||
});
|
||||
|
||||
// If the tool result also contains <feedback>, extract it
|
||||
@@ -565,6 +566,7 @@ fn parse_content_blocks(content: &serde_json::Value) -> Vec<ContentBlock> {
|
||||
tool_use_id,
|
||||
output_preview,
|
||||
is_error,
|
||||
agent_stats: None,
|
||||
});
|
||||
}
|
||||
"thinking" => {
|
||||
|
||||
@@ -719,6 +719,7 @@ impl CodexParser {
|
||||
tool_use_id,
|
||||
output_preview: output,
|
||||
is_error,
|
||||
agent_stats: None,
|
||||
}],
|
||||
timestamp,
|
||||
usage: None,
|
||||
|
||||
@@ -434,6 +434,7 @@ impl GeminiParser {
|
||||
tool_use_id,
|
||||
output_preview,
|
||||
is_error,
|
||||
agent_stats: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1053,6 +1053,7 @@ fn extract_tool_result_content(value: &serde_json::Value) -> Vec<ContentBlock> {
|
||||
tool_use_id,
|
||||
output_preview: output,
|
||||
is_error,
|
||||
agent_stats: None,
|
||||
});
|
||||
|
||||
blocks
|
||||
@@ -1238,7 +1239,7 @@ mod tests {
|
||||
assert_eq!(blocks.len(), 1);
|
||||
assert!(matches!(
|
||||
&blocks[0],
|
||||
ContentBlock::ToolResult { tool_use_id, output_preview, is_error }
|
||||
ContentBlock::ToolResult { tool_use_id, output_preview, is_error, .. }
|
||||
if tool_use_id.as_deref() == Some("call_123")
|
||||
&& output_preview.as_deref() == Some("file contents here")
|
||||
&& !is_error
|
||||
|
||||
@@ -393,6 +393,7 @@ impl OpenCodeParser {
|
||||
tool_use_id: call_id,
|
||||
output_preview,
|
||||
is_error: is_error_status(status) || has_error_field,
|
||||
agent_stats: None,
|
||||
});
|
||||
}
|
||||
"file" => {
|
||||
|
||||
Reference in New Issue
Block a user