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,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
pub enum ContentBlock {
|
pub enum ContentBlock {
|
||||||
@@ -31,6 +73,8 @@ pub enum ContentBlock {
|
|||||||
tool_use_id: Option<String>,
|
tool_use_id: Option<String>,
|
||||||
output_preview: Option<String>,
|
output_preview: Option<String>,
|
||||||
is_error: bool,
|
is_error: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
agent_stats: Option<AgentExecutionStats>,
|
||||||
},
|
},
|
||||||
Thinking {
|
Thinking {
|
||||||
text: String,
|
text: String,
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ pub use conversation::{
|
|||||||
SidebarData,
|
SidebarData,
|
||||||
};
|
};
|
||||||
pub use folder::{FolderCommandInfo, FolderDetail, FolderHistoryEntry, OpenedConversation};
|
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::{
|
pub use system::{
|
||||||
GitCredentials, GitDetectResult, GitHubAccountsSettings, GitHubTokenValidation, GitSettings,
|
GitCredentials, GitDetectResult, GitHubAccountsSettings, GitHubTokenValidation, GitSettings,
|
||||||
SystemLanguageSettings, SystemProxySettings,
|
SystemLanguageSettings, SystemProxySettings,
|
||||||
|
|||||||
@@ -558,7 +558,7 @@ impl ClaudeParser {
|
|||||||
MessageRole::User
|
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(tur) = value.get("toolUseResult") {
|
||||||
if let Some(sp) = tur.get("structuredPatch") {
|
if let Some(sp) = tur.get("structuredPatch") {
|
||||||
let fp = tur
|
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 {
|
messages.push(UnifiedMessage {
|
||||||
@@ -776,6 +811,7 @@ impl ClaudeParser {
|
|||||||
tool_use_id: matching_id,
|
tool_use_id: matching_id,
|
||||||
output_preview,
|
output_preview,
|
||||||
is_error,
|
is_error,
|
||||||
|
agent_stats: None,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
messages.push(UnifiedMessage {
|
messages.push(UnifiedMessage {
|
||||||
@@ -785,6 +821,7 @@ impl ClaudeParser {
|
|||||||
tool_use_id: matching_id,
|
tool_use_id: matching_id,
|
||||||
output_preview,
|
output_preview,
|
||||||
is_error,
|
is_error,
|
||||||
|
agent_stats: None,
|
||||||
}],
|
}],
|
||||||
timestamp: parse_timestamp(&value).unwrap_or_else(Utc::now),
|
timestamp: parse_timestamp(&value).unwrap_or_else(Utc::now),
|
||||||
usage: None,
|
usage: None,
|
||||||
@@ -912,6 +949,7 @@ fn extract_user_content(value: &serde_json::Value) -> Vec<ContentBlock> {
|
|||||||
tool_use_id,
|
tool_use_id,
|
||||||
output_preview: output,
|
output_preview: output,
|
||||||
is_error,
|
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> {
|
fn extract_tool_result_text(item: &serde_json::Value) -> Option<String> {
|
||||||
let content = item.get("content")?;
|
let content = item.get("content")?;
|
||||||
if let Some(text) = content.as_str() {
|
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,
|
tool_use_id: None,
|
||||||
output_preview: Some(truncate_str(&output, 2000)),
|
output_preview: Some(truncate_str(&output, 2000)),
|
||||||
is_error,
|
is_error,
|
||||||
|
agent_stats: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
// If the tool result also contains <feedback>, extract it
|
// 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,
|
tool_use_id,
|
||||||
output_preview,
|
output_preview,
|
||||||
is_error,
|
is_error,
|
||||||
|
agent_stats: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
"thinking" => {
|
"thinking" => {
|
||||||
|
|||||||
@@ -719,6 +719,7 @@ impl CodexParser {
|
|||||||
tool_use_id,
|
tool_use_id,
|
||||||
output_preview: output,
|
output_preview: output,
|
||||||
is_error,
|
is_error,
|
||||||
|
agent_stats: None,
|
||||||
}],
|
}],
|
||||||
timestamp,
|
timestamp,
|
||||||
usage: None,
|
usage: None,
|
||||||
|
|||||||
@@ -434,6 +434,7 @@ impl GeminiParser {
|
|||||||
tool_use_id,
|
tool_use_id,
|
||||||
output_preview,
|
output_preview,
|
||||||
is_error,
|
is_error,
|
||||||
|
agent_stats: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1053,6 +1053,7 @@ fn extract_tool_result_content(value: &serde_json::Value) -> Vec<ContentBlock> {
|
|||||||
tool_use_id,
|
tool_use_id,
|
||||||
output_preview: output,
|
output_preview: output,
|
||||||
is_error,
|
is_error,
|
||||||
|
agent_stats: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
blocks
|
blocks
|
||||||
@@ -1238,7 +1239,7 @@ mod tests {
|
|||||||
assert_eq!(blocks.len(), 1);
|
assert_eq!(blocks.len(), 1);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
&blocks[0],
|
&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")
|
if tool_use_id.as_deref() == Some("call_123")
|
||||||
&& output_preview.as_deref() == Some("file contents here")
|
&& output_preview.as_deref() == Some("file contents here")
|
||||||
&& !is_error
|
&& !is_error
|
||||||
|
|||||||
@@ -393,6 +393,7 @@ impl OpenCodeParser {
|
|||||||
tool_use_id: call_id,
|
tool_use_id: call_id,
|
||||||
output_preview,
|
output_preview,
|
||||||
is_error: is_error_status(status) || has_error_field,
|
is_error: is_error_status(status) || has_error_field,
|
||||||
|
agent_stats: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
"file" => {
|
"file" => {
|
||||||
|
|||||||
269
src/components/message/agent-tool-call.tsx
Normal file
269
src/components/message/agent-tool-call.tsx
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
import { memo, useMemo, useState, type ReactNode } from "react"
|
||||||
|
import type { AdaptedContentPart } from "@/lib/adapters/ai-elements-adapter"
|
||||||
|
import type { AgentToolCall } from "@/lib/types"
|
||||||
|
import { tryParseJson, extractJsonField } from "./content-parts-renderer"
|
||||||
|
import { MessageResponse } from "@/components/ai-elements/message"
|
||||||
|
import { Shimmer } from "@/components/ai-elements/shimmer"
|
||||||
|
import { getStatusBadge } from "@/components/ai-elements/tool"
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "@/components/ui/collapsible"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
CompassIcon,
|
||||||
|
MapIcon,
|
||||||
|
TerminalIcon,
|
||||||
|
WrenchIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
|
// ── helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ICON_CLASS = "size-4 text-muted-foreground"
|
||||||
|
|
||||||
|
function getAgentIcon(subagentType: string | null) {
|
||||||
|
const t = subagentType?.toLowerCase() ?? ""
|
||||||
|
if (t.includes("explore")) return <CompassIcon className={ICON_CLASS} />
|
||||||
|
if (t.includes("plan")) return <MapIcon className={ICON_CLASS} />
|
||||||
|
if (t.includes("bash")) return <TerminalIcon className={ICON_CLASS} />
|
||||||
|
return <WrenchIcon className={ICON_CLASS} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAccentColor(subagentType: string | null): string {
|
||||||
|
const t = subagentType?.toLowerCase() ?? ""
|
||||||
|
if (t.includes("explore"))
|
||||||
|
return "border-l-blue-500/50 dark:border-l-blue-400/40"
|
||||||
|
if (t.includes("plan"))
|
||||||
|
return "border-l-amber-500/50 dark:border-l-amber-400/40"
|
||||||
|
if (t.includes("bash"))
|
||||||
|
return "border-l-green-500/50 dark:border-l-green-400/40"
|
||||||
|
return "border-l-purple-500/50 dark:border-l-purple-400/40"
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(ms: number): string {
|
||||||
|
if (ms < 1000) return `${ms}ms`
|
||||||
|
const sec = ms / 1000
|
||||||
|
if (sec < 60) return `${sec.toFixed(1)}s`
|
||||||
|
return `${(sec / 60).toFixed(1)}m`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert AgentToolCall[] to AdaptedContentPart[] for reuse with ToolCallPart */
|
||||||
|
function adaptToolCalls(
|
||||||
|
calls: AgentToolCall[],
|
||||||
|
parentId: string
|
||||||
|
): AdaptedContentPart[] {
|
||||||
|
return calls.map(
|
||||||
|
(call, i): Extract<AdaptedContentPart, { type: "tool-call" }> => ({
|
||||||
|
type: "tool-call",
|
||||||
|
toolCallId: `${parentId}-sub-${i}`,
|
||||||
|
toolName: call.tool_name,
|
||||||
|
input: call.input_preview ?? null,
|
||||||
|
state: call.is_error ? "output-error" : "output-available",
|
||||||
|
output: call.output_preview ?? null,
|
||||||
|
errorText: call.is_error ? (call.output_preview ?? undefined) : undefined,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── main component ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const AgentToolCallPart = memo(function AgentToolCallPart({
|
||||||
|
part,
|
||||||
|
renderToolCall,
|
||||||
|
}: {
|
||||||
|
part: Extract<AdaptedContentPart, { type: "tool-call" }>
|
||||||
|
/** Render a single tool-call part — injected by the parent to avoid
|
||||||
|
* circular imports (content-parts-renderer → agent-tool-call → renderer). */
|
||||||
|
renderToolCall: (
|
||||||
|
part: Extract<AdaptedContentPart, { type: "tool-call" }>,
|
||||||
|
key: string
|
||||||
|
) => ReactNode
|
||||||
|
}) {
|
||||||
|
const t = useTranslations("Folder.chat.contentParts")
|
||||||
|
const tTool = useTranslations("Folder.chat.tool")
|
||||||
|
|
||||||
|
const isRunning =
|
||||||
|
part.state === "input-available" || part.state === "input-streaming"
|
||||||
|
const isError = part.state === "output-error"
|
||||||
|
|
||||||
|
const [bodyOpen, setBodyOpen] = useState(isRunning || isError)
|
||||||
|
const [promptOpen, setPromptOpen] = useState(false)
|
||||||
|
|
||||||
|
const parsed = useMemo(
|
||||||
|
() => (part.input ? tryParseJson(part.input) : null),
|
||||||
|
[part.input]
|
||||||
|
)
|
||||||
|
|
||||||
|
const subagentType = useMemo(
|
||||||
|
() =>
|
||||||
|
(parsed?.subagent_type as string | undefined) ??
|
||||||
|
(part.input ? extractJsonField(part.input, "subagent_type") : null),
|
||||||
|
[parsed, part.input]
|
||||||
|
)
|
||||||
|
|
||||||
|
const description = useMemo(
|
||||||
|
() =>
|
||||||
|
(parsed?.description as string | undefined) ??
|
||||||
|
(part.input ? extractJsonField(part.input, "description") : null),
|
||||||
|
[parsed, part.input]
|
||||||
|
)
|
||||||
|
|
||||||
|
const prompt = useMemo(
|
||||||
|
() =>
|
||||||
|
(parsed?.prompt as string | undefined) ??
|
||||||
|
(part.input ? extractJsonField(part.input, "prompt") : null),
|
||||||
|
[parsed, part.input]
|
||||||
|
)
|
||||||
|
|
||||||
|
const model = useMemo(
|
||||||
|
() =>
|
||||||
|
(parsed?.model as string | undefined) ??
|
||||||
|
(part.input ? extractJsonField(part.input, "model") : null),
|
||||||
|
[parsed, part.input]
|
||||||
|
)
|
||||||
|
|
||||||
|
const icon = useMemo(() => getAgentIcon(subagentType), [subagentType])
|
||||||
|
const accentColor = useMemo(
|
||||||
|
() => getAccentColor(subagentType),
|
||||||
|
[subagentType]
|
||||||
|
)
|
||||||
|
|
||||||
|
const title = useMemo(() => {
|
||||||
|
const prefix = subagentType ?? "Agent"
|
||||||
|
return description ? `${prefix}: ${description}` : prefix
|
||||||
|
}, [subagentType, description])
|
||||||
|
|
||||||
|
const statusLabel =
|
||||||
|
part.state === "input-available"
|
||||||
|
? tTool("status.inputAvailable")
|
||||||
|
: part.state === "input-streaming"
|
||||||
|
? tTool("status.inputStreaming")
|
||||||
|
: part.state === "output-available"
|
||||||
|
? tTool("status.outputAvailable")
|
||||||
|
: tTool("status.outputError")
|
||||||
|
|
||||||
|
const agentStats = part.agentStats ?? null
|
||||||
|
const adaptedToolCalls = useMemo(
|
||||||
|
() => adaptToolCalls(agentStats?.tool_calls ?? [], part.toolCallId),
|
||||||
|
[agentStats?.tool_calls, part.toolCallId]
|
||||||
|
)
|
||||||
|
|
||||||
|
const durationSuffix = useMemo(() => {
|
||||||
|
if (!agentStats?.total_duration_ms) return null
|
||||||
|
return formatDuration(agentStats.total_duration_ms)
|
||||||
|
}, [agentStats])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible open={bodyOpen} onOpenChange={setBodyOpen}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-md border border-border/60 bg-muted/20 overflow-hidden",
|
||||||
|
"border-l-[3px]",
|
||||||
|
accentColor
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header — clickable to toggle body */}
|
||||||
|
<CollapsibleTrigger className="flex w-full min-w-0 items-center justify-between gap-3 px-4 py-3">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<span className="shrink-0">{icon}</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate whitespace-nowrap text-sm font-medium text-left">
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
{!bodyOpen && durationSuffix && (
|
||||||
|
<span className="shrink-0 text-xs text-muted-foreground/60">
|
||||||
|
{durationSuffix}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
{getStatusBadge(part.state, statusLabel)}
|
||||||
|
<ChevronDownIcon
|
||||||
|
className={cn(
|
||||||
|
"size-4 text-muted-foreground transition-transform",
|
||||||
|
!bodyOpen && "-rotate-90"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
|
||||||
|
{/* Collapsible body */}
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="space-y-3 px-4 pb-4">
|
||||||
|
{/* Model + duration summary */}
|
||||||
|
{(model || durationSuffix) && (
|
||||||
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
{model && (
|
||||||
|
<span>
|
||||||
|
{t("agentModelLabel")}:{" "}
|
||||||
|
<span className="font-mono">{model}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{durationSuffix && <span>{durationSuffix}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Collapsible prompt */}
|
||||||
|
{prompt && (
|
||||||
|
<Collapsible open={promptOpen} onOpenChange={setPromptOpen}>
|
||||||
|
<CollapsibleTrigger className="flex items-center gap-1 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors">
|
||||||
|
<ChevronRightIcon
|
||||||
|
className={cn(
|
||||||
|
"size-3.5 transition-transform",
|
||||||
|
promptOpen && "rotate-90"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{t("agentPromptLabel")}
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<pre className="mt-2 max-h-64 overflow-y-auto whitespace-pre-wrap break-words rounded-md bg-muted/50 p-3 text-xs text-muted-foreground">
|
||||||
|
{prompt}
|
||||||
|
</pre>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Subagent tool calls — rendered with the same ToolCallPart
|
||||||
|
as the outer conversation for consistent appearance */}
|
||||||
|
{adaptedToolCalls.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{adaptedToolCalls.map((tc, i) =>
|
||||||
|
renderToolCall(
|
||||||
|
tc as Extract<AdaptedContentPart, { type: "tool-call" }>,
|
||||||
|
`subagent-tc-${i}`
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Running indicator */}
|
||||||
|
{isRunning && !part.output && (
|
||||||
|
<Shimmer className="text-sm" duration={2}>
|
||||||
|
Running...
|
||||||
|
</Shimmer>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error output */}
|
||||||
|
{isError && part.errorText && (
|
||||||
|
<div className="rounded-md bg-destructive/10 p-3">
|
||||||
|
<pre className="whitespace-pre-wrap break-words text-xs text-destructive">
|
||||||
|
{part.errorText}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Final output */}
|
||||||
|
{part.output && (
|
||||||
|
<div className="text-sm prose prose-sm dark:prose-invert max-w-none [&_ul]:list-inside [&_ol]:list-inside">
|
||||||
|
<MessageResponse>{part.output}</MessageResponse>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</div>
|
||||||
|
</Collapsible>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
ReasoningTrigger,
|
ReasoningTrigger,
|
||||||
ReasoningContent,
|
ReasoningContent,
|
||||||
} from "@/components/ai-elements/reasoning"
|
} from "@/components/ai-elements/reasoning"
|
||||||
|
import { AgentToolCallPart } from "./agent-tool-call"
|
||||||
import {
|
import {
|
||||||
FileTextIcon,
|
FileTextIcon,
|
||||||
FilePenLineIcon,
|
FilePenLineIcon,
|
||||||
@@ -45,7 +46,7 @@ import {
|
|||||||
// ── helpers ────────────────────────────────────────────────────────────
|
// ── helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Try JSON.parse; return null on failure. */
|
/** Try JSON.parse; return null on failure. */
|
||||||
function tryParseJson(s: string): Record<string, unknown> | null {
|
export function tryParseJson(s: string): Record<string, unknown> | null {
|
||||||
try {
|
try {
|
||||||
const v = JSON.parse(s)
|
const v = JSON.parse(s)
|
||||||
return typeof v === "object" && v !== null && !Array.isArray(v) ? v : null
|
return typeof v === "object" && v !== null && !Array.isArray(v) ? v : null
|
||||||
@@ -55,7 +56,7 @@ function tryParseJson(s: string): Record<string, unknown> | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Regex-extract a JSON string value for a given key (works on truncated JSON). */
|
/** Regex-extract a JSON string value for a given key (works on truncated JSON). */
|
||||||
function extractJsonField(input: string, key: string): string | null {
|
export function extractJsonField(input: string, key: string): string | null {
|
||||||
const re = new RegExp(`"${key}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`)
|
const re = new RegExp(`"${key}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`)
|
||||||
const m = input.match(re)
|
const m = input.match(re)
|
||||||
return m?.[1]?.replace(/\\"/g, '"').replace(/\\\\/g, "\\") ?? null
|
return m?.[1]?.replace(/\\"/g, '"').replace(/\\\\/g, "\\") ?? null
|
||||||
@@ -191,7 +192,7 @@ function shortPath(p: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Truncate text to maxLen, appending "…" if truncated. */
|
/** Truncate text to maxLen, appending "…" if truncated. */
|
||||||
function ellipsis(s: string, maxLen: number): string {
|
export function ellipsis(s: string, maxLen: number): string {
|
||||||
return s.length > maxLen ? s.slice(0, maxLen - 1) + "…" : s
|
return s.length > maxLen ? s.slice(0, maxLen - 1) + "…" : s
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2240,6 +2241,19 @@ const ToolCallPart = memo(function ToolCallPart({
|
|||||||
toolNameLower === "exitplanmode" ||
|
toolNameLower === "exitplanmode" ||
|
||||||
isFileTool) &&
|
isFileTool) &&
|
||||||
!part.errorText
|
!part.errorText
|
||||||
|
// Agent/subagent tools get a dedicated container rendering
|
||||||
|
if (toolNameLower === "agent") {
|
||||||
|
return (
|
||||||
|
<AgentToolCallPart
|
||||||
|
part={part}
|
||||||
|
renderToolCall={(p, key) => (
|
||||||
|
// Strip agentStats to prevent recursive Agent nesting
|
||||||
|
<ToolCallPart key={key} part={{ ...p, agentStats: undefined }} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Cline: attempt_completion — render as an expanded card with result + progress
|
// Cline: attempt_completion — render as an expanded card with result + progress
|
||||||
if (toolNameLower === "attempt_completion") {
|
if (toolNameLower === "attempt_completion") {
|
||||||
const parsedCompletion = tryParseJson(part.input ?? "")
|
const parsedCompletion = tryParseJson(part.input ?? "")
|
||||||
@@ -2297,8 +2311,7 @@ const ToolCallPart = memo(function ToolCallPart({
|
|||||||
output={part.output}
|
output={part.output}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{(toolNameLower === "task" || toolNameLower === "agent") &&
|
{toolNameLower === "task" && part.output ? (
|
||||||
part.output ? (
|
|
||||||
<div className="text-sm prose prose-sm dark:prose-invert max-w-none [&_ul]:list-inside [&_ol]:list-inside">
|
<div className="text-sm prose prose-sm dark:prose-invert max-w-none [&_ul]:list-inside [&_ol]:list-inside">
|
||||||
<MessageResponse>{part.output}</MessageResponse>
|
<MessageResponse>{part.output}</MessageResponse>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1703,6 +1703,13 @@
|
|||||||
"subjectLabel": "الموضوع",
|
"subjectLabel": "الموضوع",
|
||||||
"taskLabel": "المهمة",
|
"taskLabel": "المهمة",
|
||||||
"nameLabel": "الاسم:",
|
"nameLabel": "الاسم:",
|
||||||
|
"agentPromptLabel": "المطالبة",
|
||||||
|
"agentModelLabel": "النموذج",
|
||||||
|
"agentStatsBash": "الأوامر",
|
||||||
|
"agentStatsRead": "الملفات المقروءة",
|
||||||
|
"agentStatsSearch": "عمليات البحث",
|
||||||
|
"agentStatsEdit": "التعديلات",
|
||||||
|
"agentStatsOther": "أخرى",
|
||||||
"field": {
|
"field": {
|
||||||
"file": "ملف",
|
"file": "ملف",
|
||||||
"notebook": "دفتر",
|
"notebook": "دفتر",
|
||||||
|
|||||||
@@ -1703,6 +1703,13 @@
|
|||||||
"subjectLabel": "Betreff",
|
"subjectLabel": "Betreff",
|
||||||
"taskLabel": "Aufgabe",
|
"taskLabel": "Aufgabe",
|
||||||
"nameLabel": "Bezeichnung:",
|
"nameLabel": "Bezeichnung:",
|
||||||
|
"agentPromptLabel": "Eingabe",
|
||||||
|
"agentModelLabel": "Modell",
|
||||||
|
"agentStatsBash": "Befehle",
|
||||||
|
"agentStatsRead": "Dateien gelesen",
|
||||||
|
"agentStatsSearch": "Suchen",
|
||||||
|
"agentStatsEdit": "Bearbeitungen",
|
||||||
|
"agentStatsOther": "Sonstige",
|
||||||
"field": {
|
"field": {
|
||||||
"file": "Datei",
|
"file": "Datei",
|
||||||
"notebook": "Notizbuch",
|
"notebook": "Notizbuch",
|
||||||
|
|||||||
@@ -1703,6 +1703,13 @@
|
|||||||
"subjectLabel": "Subject",
|
"subjectLabel": "Subject",
|
||||||
"taskLabel": "Task",
|
"taskLabel": "Task",
|
||||||
"nameLabel": "Name:",
|
"nameLabel": "Name:",
|
||||||
|
"agentPromptLabel": "Prompt",
|
||||||
|
"agentModelLabel": "Model",
|
||||||
|
"agentStatsBash": "Commands",
|
||||||
|
"agentStatsRead": "Files read",
|
||||||
|
"agentStatsSearch": "Searches",
|
||||||
|
"agentStatsEdit": "Edits",
|
||||||
|
"agentStatsOther": "Other",
|
||||||
"field": {
|
"field": {
|
||||||
"file": "File",
|
"file": "File",
|
||||||
"notebook": "Notebook",
|
"notebook": "Notebook",
|
||||||
|
|||||||
@@ -1703,6 +1703,13 @@
|
|||||||
"subjectLabel": "Asunto",
|
"subjectLabel": "Asunto",
|
||||||
"taskLabel": "Tarea",
|
"taskLabel": "Tarea",
|
||||||
"nameLabel": "Nombre:",
|
"nameLabel": "Nombre:",
|
||||||
|
"agentPromptLabel": "Instrucción",
|
||||||
|
"agentModelLabel": "Modelo",
|
||||||
|
"agentStatsBash": "Comandos",
|
||||||
|
"agentStatsRead": "Archivos leídos",
|
||||||
|
"agentStatsSearch": "Búsquedas",
|
||||||
|
"agentStatsEdit": "Ediciones",
|
||||||
|
"agentStatsOther": "Otros",
|
||||||
"field": {
|
"field": {
|
||||||
"file": "Archivo",
|
"file": "Archivo",
|
||||||
"notebook": "Cuaderno",
|
"notebook": "Cuaderno",
|
||||||
|
|||||||
@@ -1703,6 +1703,13 @@
|
|||||||
"subjectLabel": "Sujet",
|
"subjectLabel": "Sujet",
|
||||||
"taskLabel": "Tâche",
|
"taskLabel": "Tâche",
|
||||||
"nameLabel": "Nom :",
|
"nameLabel": "Nom :",
|
||||||
|
"agentPromptLabel": "Instruction",
|
||||||
|
"agentModelLabel": "Modèle",
|
||||||
|
"agentStatsBash": "Commandes",
|
||||||
|
"agentStatsRead": "Fichiers lus",
|
||||||
|
"agentStatsSearch": "Recherches",
|
||||||
|
"agentStatsEdit": "Modifications",
|
||||||
|
"agentStatsOther": "Autres",
|
||||||
"field": {
|
"field": {
|
||||||
"file": "Fichier",
|
"file": "Fichier",
|
||||||
"notebook": "Carnet",
|
"notebook": "Carnet",
|
||||||
|
|||||||
@@ -1703,6 +1703,13 @@
|
|||||||
"subjectLabel": "件名",
|
"subjectLabel": "件名",
|
||||||
"taskLabel": "タスク",
|
"taskLabel": "タスク",
|
||||||
"nameLabel": "名前:",
|
"nameLabel": "名前:",
|
||||||
|
"agentPromptLabel": "プロンプト",
|
||||||
|
"agentModelLabel": "モデル",
|
||||||
|
"agentStatsBash": "コマンド",
|
||||||
|
"agentStatsRead": "ファイル読取",
|
||||||
|
"agentStatsSearch": "検索",
|
||||||
|
"agentStatsEdit": "編集",
|
||||||
|
"agentStatsOther": "その他",
|
||||||
"field": {
|
"field": {
|
||||||
"file": "ファイル",
|
"file": "ファイル",
|
||||||
"notebook": "ノートブック",
|
"notebook": "ノートブック",
|
||||||
|
|||||||
@@ -1703,6 +1703,13 @@
|
|||||||
"subjectLabel": "제목",
|
"subjectLabel": "제목",
|
||||||
"taskLabel": "작업",
|
"taskLabel": "작업",
|
||||||
"nameLabel": "이름:",
|
"nameLabel": "이름:",
|
||||||
|
"agentPromptLabel": "프롬프트",
|
||||||
|
"agentModelLabel": "모델",
|
||||||
|
"agentStatsBash": "명령",
|
||||||
|
"agentStatsRead": "파일 읽기",
|
||||||
|
"agentStatsSearch": "검색",
|
||||||
|
"agentStatsEdit": "편집",
|
||||||
|
"agentStatsOther": "기타",
|
||||||
"field": {
|
"field": {
|
||||||
"file": "파일",
|
"file": "파일",
|
||||||
"notebook": "노트북",
|
"notebook": "노트북",
|
||||||
|
|||||||
@@ -1703,6 +1703,13 @@
|
|||||||
"subjectLabel": "Assunto",
|
"subjectLabel": "Assunto",
|
||||||
"taskLabel": "Tarefa",
|
"taskLabel": "Tarefa",
|
||||||
"nameLabel": "Nome:",
|
"nameLabel": "Nome:",
|
||||||
|
"agentPromptLabel": "Instrução",
|
||||||
|
"agentModelLabel": "Modelo",
|
||||||
|
"agentStatsBash": "Comandos",
|
||||||
|
"agentStatsRead": "Arquivos lidos",
|
||||||
|
"agentStatsSearch": "Pesquisas",
|
||||||
|
"agentStatsEdit": "Edições",
|
||||||
|
"agentStatsOther": "Outros",
|
||||||
"field": {
|
"field": {
|
||||||
"file": "Arquivo",
|
"file": "Arquivo",
|
||||||
"notebook": "Caderno",
|
"notebook": "Caderno",
|
||||||
|
|||||||
@@ -1703,6 +1703,13 @@
|
|||||||
"subjectLabel": "主题",
|
"subjectLabel": "主题",
|
||||||
"taskLabel": "任务",
|
"taskLabel": "任务",
|
||||||
"nameLabel": "名称:",
|
"nameLabel": "名称:",
|
||||||
|
"agentPromptLabel": "提示词",
|
||||||
|
"agentModelLabel": "模型",
|
||||||
|
"agentStatsBash": "命令",
|
||||||
|
"agentStatsRead": "读取文件",
|
||||||
|
"agentStatsSearch": "搜索",
|
||||||
|
"agentStatsEdit": "编辑",
|
||||||
|
"agentStatsOther": "其他",
|
||||||
"field": {
|
"field": {
|
||||||
"file": "文件",
|
"file": "文件",
|
||||||
"notebook": "笔记本",
|
"notebook": "笔记本",
|
||||||
|
|||||||
@@ -1703,6 +1703,13 @@
|
|||||||
"subjectLabel": "主題",
|
"subjectLabel": "主題",
|
||||||
"taskLabel": "任務",
|
"taskLabel": "任務",
|
||||||
"nameLabel": "名稱:",
|
"nameLabel": "名稱:",
|
||||||
|
"agentPromptLabel": "提示詞",
|
||||||
|
"agentModelLabel": "模型",
|
||||||
|
"agentStatsBash": "命令",
|
||||||
|
"agentStatsRead": "讀取檔案",
|
||||||
|
"agentStatsSearch": "搜尋",
|
||||||
|
"agentStatsEdit": "編輯",
|
||||||
|
"agentStatsOther": "其他",
|
||||||
"field": {
|
"field": {
|
||||||
"file": "檔案",
|
"file": "檔案",
|
||||||
"notebook": "筆記本",
|
"notebook": "筆記本",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
ContentBlock,
|
ContentBlock,
|
||||||
MessageRole,
|
MessageRole,
|
||||||
TurnUsage,
|
TurnUsage,
|
||||||
|
AgentExecutionStats,
|
||||||
} from "@/lib/types"
|
} from "@/lib/types"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,6 +26,7 @@ export type AdaptedContentPart =
|
|||||||
state: ToolCallState
|
state: ToolCallState
|
||||||
output?: string | null
|
output?: string | null
|
||||||
errorText?: string
|
errorText?: string
|
||||||
|
agentStats?: AgentExecutionStats | null
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: "tool-result"
|
type: "tool-result"
|
||||||
@@ -662,6 +664,7 @@ export function adaptMessageTurn(
|
|||||||
errorText: matchedResult.is_error
|
errorText: matchedResult.is_error
|
||||||
? matchedResult.output_preview || undefined
|
? matchedResult.output_preview || undefined
|
||||||
: undefined,
|
: undefined,
|
||||||
|
agentStats: matchedResult.agent_stats ?? undefined,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// Position-based matching: if this tool_use has no ID, check next block
|
// Position-based matching: if this tool_use has no ID, check next block
|
||||||
@@ -687,6 +690,7 @@ export function adaptMessageTurn(
|
|||||||
errorText: positionalResult.is_error
|
errorText: positionalResult.is_error
|
||||||
? positionalResult.output_preview || undefined
|
? positionalResult.output_preview || undefined
|
||||||
: undefined,
|
: undefined,
|
||||||
|
agentStats: positionalResult.agent_stats ?? undefined,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// For live streaming, unmatched tools are still running.
|
// For live streaming, unmatched tools are still running.
|
||||||
|
|||||||
@@ -44,6 +44,29 @@ export interface ConversationSummary {
|
|||||||
|
|
||||||
export type MessageRole = "user" | "assistant" | "system" | "tool"
|
export type MessageRole = "user" | "assistant" | "system" | "tool"
|
||||||
|
|
||||||
|
export interface AgentToolCall {
|
||||||
|
tool_name: string
|
||||||
|
input_preview?: string | null
|
||||||
|
output_preview?: string | null
|
||||||
|
is_error: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentExecutionStats {
|
||||||
|
agent_type?: string | null
|
||||||
|
status?: string | null
|
||||||
|
total_duration_ms?: number | null
|
||||||
|
total_tokens?: number | null
|
||||||
|
total_tool_use_count?: number | null
|
||||||
|
read_count?: number | null
|
||||||
|
search_count?: number | null
|
||||||
|
bash_count?: number | null
|
||||||
|
edit_file_count?: number | null
|
||||||
|
lines_added?: number | null
|
||||||
|
lines_removed?: number | null
|
||||||
|
other_tool_count?: number | null
|
||||||
|
tool_calls?: AgentToolCall[]
|
||||||
|
}
|
||||||
|
|
||||||
export type ContentBlock =
|
export type ContentBlock =
|
||||||
| { type: "text"; text: string }
|
| { type: "text"; text: string }
|
||||||
| {
|
| {
|
||||||
@@ -63,6 +86,7 @@ export type ContentBlock =
|
|||||||
tool_use_id: string | null
|
tool_use_id: string | null
|
||||||
output_preview: string | null
|
output_preview: string | null
|
||||||
is_error: boolean
|
is_error: boolean
|
||||||
|
agent_stats?: AgentExecutionStats | null
|
||||||
}
|
}
|
||||||
| { type: "thinking"; text: string }
|
| { type: "thinking"; text: string }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user