Display tool call arguments and results for misc/mcp tools and bash commands (#563)

This commit is contained in:
Solomon
2025-08-28 09:43:59 +01:00
committed by GitHub
parent f8ff901119
commit 5538d4bbca
10 changed files with 1655 additions and 153 deletions

View File

@@ -111,6 +111,8 @@ impl StandardCodingAgentExecutor for Amp {
let mut s = raw_logs_msg_store.stdout_lines_stream();
let mut seen_amp_message_ids: HashMap<usize, Vec<usize>> = HashMap::new();
// Consolidated tool state keyed by toolUseID
let mut tool_records: HashMap<String, ToolRecord> = HashMap::new();
while let Some(Ok(line)) = s.next().await {
let trimmed = line.trim();
match serde_json::from_str(trimmed) {
@@ -119,18 +121,18 @@ impl StandardCodingAgentExecutor for Amp {
messages,
tool_results,
} => {
for (amp_message_id, message) in messages {
for (amp_message_id, message) in &messages {
let role = &message.role;
for (content_index, content_item) in
message.content.iter().enumerate()
{
let mut has_patch_ids =
seen_amp_message_ids.get_mut(&amp_message_id);
seen_amp_message_ids.get_mut(amp_message_id);
if let Some(entry) = content_item.to_normalized_entry(
if let Some(mut entry) = content_item.to_normalized_entry(
role,
&message,
message,
&current_dir.to_string_lossy(),
) {
// Text
@@ -145,26 +147,65 @@ impl StandardCodingAgentExecutor for Amp {
);
}
entry_index_provider.reset();
// Clear tool state on new user message to avoid stale mappings
tool_records.clear();
}
// Consolidate tool state and refine concise content
if let AmpContentItem::ToolUse { id, tool_data } =
content_item
{
let rec = tool_records.entry(id.clone()).or_default();
rec.tool_name = Some(tool_data.get_name().to_string());
if let Some(new_content) = rec
.update_tool_content_from_tool_input(
tool_data,
&current_dir.to_string_lossy(),
)
{
entry.content = new_content;
}
rec.update_concise(&entry.content);
}
let patch: Patch = match &mut has_patch_ids {
None => {
let new_id = entry_index_provider.next();
seen_amp_message_ids
.entry(amp_message_id)
.entry(*amp_message_id)
.or_default()
.push(new_id);
// Track tool_use id if present
if let AmpContentItem::ToolUse { id, .. } =
content_item
&& let Some(rec) = tool_records.get_mut(id)
{
rec.entry_idx = Some(new_id);
}
ConversationPatch::add_normalized_entry(
new_id, entry,
)
}
Some(patch_ids) => match patch_ids.get(content_index) {
Some(patch_id) => {
// Update tool record's entry index
if let AmpContentItem::ToolUse { id, .. } =
content_item
&& let Some(rec) = tool_records.get_mut(id)
{
rec.entry_idx = Some(*patch_id);
}
ConversationPatch::replace(*patch_id, entry)
}
None => {
let new_id = entry_index_provider.next();
patch_ids.push(new_id);
if let AmpContentItem::ToolUse { id, .. } =
content_item
&& let Some(rec) = tool_records.get_mut(id)
{
rec.entry_idx = Some(new_id);
}
ConversationPatch::add_normalized_entry(
new_id, entry,
)
@@ -174,6 +215,94 @@ impl StandardCodingAgentExecutor for Amp {
raw_logs_msg_store.push_patch(patch);
}
// Handle tool_result messages in-stream, keyed by toolUseID
if let AmpContentItem::ToolResult {
tool_use_id,
run,
content: result_content,
} = content_item
{
let rec =
tool_records.entry(tool_use_id.clone()).or_default();
rec.run = run.clone();
rec.content_result = result_content.clone();
if let Some(idx) = rec.entry_idx
&& let Some(entry) = build_result_entry(rec)
{
raw_logs_msg_store
.push_patch(ConversationPatch::replace(idx, entry));
}
}
// No separate pending apply: handled right after ToolUse entry creation
}
}
// Also process separate toolResults pairs that may arrive outside messages
for AmpToolResultsEntry::Pair([first, second]) in tool_results {
// Normalize order: references to ToolUse then ToolResult
let (tool_use_ref, tool_result_ref) = match (&first, &second) {
(
AmpToolResultsObject::ToolUse { .. },
AmpToolResultsObject::ToolResult { .. },
) => (&first, &second),
(
AmpToolResultsObject::ToolResult { .. },
AmpToolResultsObject::ToolUse { .. },
) => (&second, &first),
_ => continue,
};
// Apply tool_use summary
let (id, name, input_val) = match tool_use_ref {
AmpToolResultsObject::ToolUse { id, name, input } => {
(id.clone(), name.clone(), input.clone())
}
_ => unreachable!(),
};
let rec = tool_records.entry(id.clone()).or_default();
rec.tool_name = Some(name.clone());
// Only update tool input/args if the input is meaningful (not empty)
if is_meaningful_input(&input_val) {
if let Some(parsed) = parse_tool_input(&name, &input_val) {
if let Some(new_content) = rec
.update_tool_content_from_tool_input(
&parsed,
&current_dir.to_string_lossy(),
)
{
rec.update_concise(&new_content);
}
} else {
rec.args = Some(input_val);
}
}
// Apply tool_result summary
if let AmpToolResultsObject::ToolResult {
tool_use_id: _,
run,
content,
} = tool_result_ref
{
rec.run = run.clone();
rec.content_result = content.clone();
}
// Render: replace existing entry or add a new one
if let Some(idx) = rec.entry_idx {
if let Some(entry) = build_result_entry(rec) {
raw_logs_msg_store
.push_patch(ConversationPatch::replace(idx, entry));
}
} else if let Some(entry) = build_result_entry(rec) {
let new_id = entry_index_provider.next();
if let Some(rec_mut) = tool_records.get_mut(&id) {
rec_mut.entry_idx = Some(new_id);
}
raw_logs_msg_store.push_patch(
ConversationPatch::add_normalized_entry(new_id, entry),
);
}
}
}
@@ -212,7 +341,7 @@ pub enum AmpJson {
Messages {
messages: Vec<(usize, AmpMessage)>,
#[serde(rename = "toolResults")]
tool_results: Vec<serde_json::Value>,
tool_results: Vec<AmpToolResultsEntry>,
},
#[serde(rename = "initial")]
Initial {
@@ -227,6 +356,15 @@ pub enum AmpJson {
Shutdown,
#[serde(rename = "tool-status")]
ToolStatus(serde_json::Value),
// Subthread/subagent noise we should ignore
#[serde(rename = "subagent-started")]
SubagentStarted(serde_json::Value),
#[serde(rename = "subagent-status")]
SubagentStatus(serde_json::Value),
#[serde(rename = "subagent-finished")]
SubagentFinished(serde_json::Value),
#[serde(rename = "subthread-activity")]
SubthreadActivity(serde_json::Value),
}
impl AmpJson {
@@ -273,6 +411,35 @@ pub struct AmpMeta {
pub sent_at: u64,
}
// Typed objects for top-level toolResults stream (outside messages)
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
#[serde(untagged)]
pub enum AmpToolResultsEntry {
// Common shape: an array of two objects [tool_use, tool_result]
Pair([AmpToolResultsObject; 2]),
}
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
#[serde(tag = "type")]
pub enum AmpToolResultsObject {
#[serde(rename = "tool_use")]
ToolUse {
id: String,
name: String,
#[serde(default)]
input: serde_json::Value,
},
#[serde(rename = "tool_result")]
ToolResult {
#[serde(rename = "toolUseID")]
tool_use_id: String,
#[serde(default)]
run: Option<AmpToolRun>,
#[serde(default)]
content: Option<serde_json::Value>,
},
}
/// Tool data combining name and input
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
#[serde(tag = "name", content = "input")]
@@ -298,7 +465,7 @@ pub enum AmpToolData {
#[serde(default)]
new_str: Option<String>,
},
#[serde(alias = "bash")]
#[serde(alias = "bash", alias = "Bash")]
Bash {
#[serde(alias = "cmd")]
command: String,
@@ -323,6 +490,7 @@ pub enum AmpToolData {
},
#[serde(alias = "glob")]
Glob {
#[serde(alias = "filePattern")]
pattern: String,
#[serde(default)]
path: Option<String>,
@@ -392,7 +560,10 @@ pub enum AmpContentItem {
ToolResult {
#[serde(rename = "toolUseID")]
tool_use_id: String,
run: serde_json::Value,
#[serde(default)]
run: Option<AmpToolRun>,
#[serde(default)]
content: Option<serde_json::Value>,
},
}
@@ -430,9 +601,7 @@ impl AmpContentItem {
AmpContentItem::ToolUse { tool_data, .. } => {
let name = tool_data.get_name();
let input = tool_data;
let action_type = Self::extract_action_type(name, input, worktree_path);
let content =
Self::generate_concise_content(name, input, &action_type, worktree_path);
let (action_type, content) = Self::action_and_content(input, worktree_path);
Some(NormalizedEntry {
timestamp,
@@ -448,11 +617,13 @@ impl AmpContentItem {
}
}
fn extract_action_type(
tool_name: &str,
input: &AmpToolData,
worktree_path: &str,
) -> ActionType {
fn action_and_content(input: &AmpToolData, worktree_path: &str) -> (ActionType, String) {
let action_type = Self::extract_action_type(input, worktree_path);
let content = Self::generate_concise_content(input, &action_type, worktree_path);
(action_type, content)
}
fn extract_action_type(input: &AmpToolData, worktree_path: &str) -> ActionType {
match input {
AmpToolData::Read { path, .. } => ActionType::FileRead {
path: make_path_relative(path, worktree_path),
@@ -495,6 +666,7 @@ impl AmpContentItem {
}
AmpToolData::Bash { command, .. } => ActionType::CommandRun {
command: command.clone(),
result: None,
},
AmpToolData::Search { pattern, .. } => ActionType::Search {
query: pattern.clone(),
@@ -527,23 +699,24 @@ impl AmpContentItem {
operation: "write".to_string(),
},
AmpToolData::Unknown { .. } => ActionType::Other {
description: format!("Tool: {tool_name}"),
description: format!("Tool: {}", input.get_name()),
},
}
}
fn generate_concise_content(
tool_name: &str,
input: &AmpToolData,
action_type: &ActionType,
worktree_path: &str,
) -> String {
let tool_name = input.get_name();
match action_type {
ActionType::FileRead { path } => format!("`{path}`"),
ActionType::FileEdit { path, .. } => format!("`{path}`"),
ActionType::CommandRun { command } => format!("`{command}`"),
ActionType::Search { query } => format!("`{query}`"),
ActionType::CommandRun { command, .. } => format!("`{command}`"),
ActionType::Search { query } => format!("Search: `{query}`"),
ActionType::WebFetch { url } => format!("`{url}`"),
ActionType::Tool { .. } => tool_name.to_string(),
ActionType::PlanPresentation { plan } => format!("Plan Presentation: `{plan}`"),
ActionType::TaskCreate { description } => description.clone(),
ActionType::TodoManagement { .. } => "TODO list updated".to_string(),
@@ -605,3 +778,271 @@ impl AmpContentItem {
}
}
}
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
pub struct AmpToolRun {
#[serde(default)]
pub result: Option<serde_json::Value>,
#[serde(default)]
pub error: Option<serde_json::Value>,
#[serde(default)]
pub status: Option<String>,
#[serde(default)]
pub progress: Option<serde_json::Value>,
// Some tools provide stdout/stderr/success at top-level under run
#[serde(default)]
pub stdout: Option<String>,
#[serde(default)]
pub stderr: Option<String>,
#[serde(default)]
pub success: Option<bool>,
}
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq, Default)]
struct BashInnerResult {
#[serde(default)]
output: Option<String>,
#[serde(default, rename = "exitCode")]
exit_code: Option<i32>,
}
#[derive(Debug, Clone, Default)]
struct ToolRecord {
entry_idx: Option<usize>,
tool_name: Option<String>,
tool_input: Option<AmpToolData>,
args: Option<serde_json::Value>,
concise_content: Option<String>,
bash_cmd: Option<String>,
run: Option<AmpToolRun>,
content_result: Option<serde_json::Value>,
}
impl ToolRecord {
fn update_concise(&mut self, new_content: &str) {
let new_is_cmd = new_content.trim_start().starts_with('`');
match self.concise_content.as_ref() {
None => self.concise_content = Some(new_content.to_string()),
Some(prev) => {
let prev_is_cmd = prev.trim_start().starts_with('`');
if !(prev_is_cmd && !new_is_cmd) {
self.concise_content = Some(new_content.to_string());
}
}
}
}
fn update_tool_content_from_tool_input(
&mut self,
tool_data: &AmpToolData,
worktree_path: &str,
) -> Option<String> {
self.tool_input = Some(tool_data.clone());
match tool_data {
AmpToolData::Task { description } => {
self.args = Some(serde_json::json!({ "description": description }));
None
}
AmpToolData::Bash { command } => {
self.bash_cmd = Some(command.clone());
None
}
AmpToolData::Glob { pattern, path } => {
self.args = Some(serde_json::json!({ "pattern": pattern, "path": path }));
// Prefer concise content derived from typed input
let (_action, content) =
AmpContentItem::action_and_content(tool_data, worktree_path);
Some(content)
}
AmpToolData::Search {
pattern,
include,
path,
} => {
self.args = Some(
serde_json::json!({ "pattern": pattern, "include": include, "path": path }),
);
None
}
AmpToolData::List { path } => {
self.args = Some(serde_json::json!({ "path": path }));
None
}
AmpToolData::Read { path }
| AmpToolData::CreateFile { path, .. }
| AmpToolData::EditFile { path, .. } => {
self.args = Some(serde_json::json!({ "path": path }));
None
}
AmpToolData::ReadWebPage { url } => {
self.args = Some(serde_json::json!({ "url": url }));
None
}
AmpToolData::WebSearch { query } => {
self.args = Some(serde_json::json!({ "query": query }));
None
}
AmpToolData::Todo { .. } => None,
AmpToolData::Unknown { data } => {
if let Some(inp) = data.get("input")
&& is_meaningful_input(inp)
{
self.args = Some(inp.clone());
let name = self
.tool_name
.clone()
.unwrap_or_else(|| tool_data.get_name().to_string());
return parse_tool_input(&name, inp).map(|parsed| {
let (_action, content) =
AmpContentItem::action_and_content(&parsed, worktree_path);
content
});
}
None
}
}
}
}
fn parse_tool_input(tool_name: &str, input: &serde_json::Value) -> Option<AmpToolData> {
let obj = serde_json::json!({ "name": tool_name, "input": input });
serde_json::from_value::<AmpToolData>(obj).ok()
}
fn is_meaningful_input(v: &serde_json::Value) -> bool {
use serde_json::Value::*;
match v {
Null => false,
Bool(_) | Number(_) => true,
String(s) => !s.trim().is_empty(),
Array(arr) => !arr.is_empty(),
Object(map) => !map.is_empty(),
}
}
fn build_result_entry(rec: &ToolRecord) -> Option<NormalizedEntry> {
let input = rec.tool_input.as_ref()?;
match input {
AmpToolData::Bash { .. } => {
let mut output: Option<String> = None;
let mut exit_status: Option<crate::logs::CommandExitStatus> = None;
if let Some(run) = &rec.run {
if let Some(res) = &run.result
&& let Ok(inner) = serde_json::from_value::<BashInnerResult>(res.clone())
{
if let Some(oc) = inner.output
&& !oc.trim().is_empty()
{
output = Some(oc);
}
if let Some(code) = inner.exit_code {
exit_status = Some(crate::logs::CommandExitStatus::ExitCode { code });
}
}
if output.is_none() {
output = match (run.stdout.clone(), run.stderr.clone()) {
(Some(sout), Some(serr)) => {
let st = sout.trim().to_string();
let se = serr.trim().to_string();
if st.is_empty() && se.is_empty() {
None
} else if st.is_empty() {
Some(serr)
} else if se.is_empty() {
Some(sout)
} else {
Some(format!("STDOUT:\n{st}\n\nSTDERR:\n{se}"))
}
}
(Some(sout), None) => {
if sout.trim().is_empty() {
None
} else {
Some(sout)
}
}
(None, Some(serr)) => {
if serr.trim().is_empty() {
None
} else {
Some(serr)
}
}
(None, None) => None,
};
}
if exit_status.is_none()
&& let Some(s) = run.success
{
exit_status = Some(crate::logs::CommandExitStatus::Success { success: s });
}
}
let cmd = rec.bash_cmd.clone().unwrap_or_default();
let content = rec
.concise_content
.clone()
.or_else(|| {
if !cmd.is_empty() {
Some(format!("`{cmd}`"))
} else {
None
}
})
.unwrap_or_else(|| input.get_name().to_string());
Some(NormalizedEntry {
timestamp: None,
entry_type: NormalizedEntryType::ToolUse {
tool_name: input.get_name().to_string(),
action_type: ActionType::CommandRun {
command: cmd,
result: Some(crate::logs::CommandRunResult {
exit_status,
output,
}),
},
},
content,
metadata: None,
})
}
AmpToolData::Read { .. }
| AmpToolData::CreateFile { .. }
| AmpToolData::EditFile { .. }
| AmpToolData::Glob { .. }
| AmpToolData::Search { .. }
| AmpToolData::List { .. }
| AmpToolData::ReadWebPage { .. }
| AmpToolData::WebSearch { .. }
| AmpToolData::Todo { .. } => None,
_ => {
// Generic tool: attach args + result as JSON
let args = rec.args.clone().unwrap_or(serde_json::Value::Null);
let render_value = rec
.run
.as_ref()
.and_then(|r| r.result.clone())
.or_else(|| rec.content_result.clone())
.unwrap_or(serde_json::Value::Null);
let content = rec
.concise_content
.clone()
.unwrap_or_else(|| input.get_name().to_string());
Some(NormalizedEntry {
timestamp: None,
entry_type: NormalizedEntryType::ToolUse {
tool_name: input.get_name().to_string(),
action_type: ActionType::Tool {
tool_name: input.get_name().to_string(),
arguments: Some(args),
result: Some(crate::logs::ToolResult {
r#type: crate::logs::ToolResultValueType::Json,
value: render_value,
}),
},
},
content,
metadata: None,
})
}
}
}

View File

@@ -153,11 +153,16 @@ exit "$exit_code"
/// Handles log processing and interpretation for Claude executor
struct ClaudeLogProcessor {
model_name: Option<String>,
// Map tool_use_id -> structured info for follow-up ToolResult replacement
tool_map: std::collections::HashMap<String, ClaudeToolCallInfo>,
}
impl ClaudeLogProcessor {
fn new() -> Self {
Self { model_name: None }
Self {
model_name: None,
tool_map: std::collections::HashMap::new(),
}
}
/// Process raw logs and convert them to normalized entries with patches
@@ -214,14 +219,240 @@ impl ClaudeLogProcessor {
session_id_extracted = true;
}
// Convert to normalized entries and create patches
for entry in
processor.to_normalized_entries(&claude_json, &worktree_path)
{
let patch_id = entry_index_provider.next();
let patch =
ConversationPatch::add_normalized_entry(patch_id, entry);
msg_store.push_patch(patch);
// Special handling to capture tool_use ids and replace with results later
match &claude_json {
ClaudeJson::Assistant { message, .. } => {
// Inject system init with model if first time
if processor.model_name.is_none()
&& let Some(model) = message.model.as_ref()
{
processor.model_name = Some(model.clone());
let entry = NormalizedEntry {
timestamp: None,
entry_type: NormalizedEntryType::SystemMessage,
content: format!(
"System initialized with model: {model}"
),
metadata: None,
};
let id = entry_index_provider.next();
msg_store.push_patch(
ConversationPatch::add_normalized_entry(id, entry),
);
}
for item in &message.content {
match item {
ClaudeContentItem::ToolUse { id, tool_data } => {
let tool_name = tool_data.get_name().to_string();
let action_type = Self::extract_action_type(
tool_data,
&worktree_path,
);
let content_text = Self::generate_concise_content(
tool_data,
&action_type,
&worktree_path,
);
let entry = NormalizedEntry {
timestamp: None,
entry_type: NormalizedEntryType::ToolUse {
tool_name: tool_name.clone(),
action_type,
},
content: content_text.clone(),
metadata: Some(
serde_json::to_value(item)
.unwrap_or(serde_json::Value::Null),
),
};
let id_num = entry_index_provider.next();
processor.tool_map.insert(
id.clone(),
ClaudeToolCallInfo {
entry_index: id_num,
tool_name: tool_name.clone(),
tool_data: tool_data.clone(),
content: content_text.clone(),
},
);
msg_store.push_patch(
ConversationPatch::add_normalized_entry(
id_num, entry,
),
);
}
ClaudeContentItem::Text { .. }
| ClaudeContentItem::Thinking { .. } => {
if let Some(entry) =
Self::content_item_to_normalized_entry(
item,
"assistant",
&worktree_path,
)
{
let id = entry_index_provider.next();
msg_store.push_patch(
ConversationPatch::add_normalized_entry(
id, entry,
),
);
}
}
ClaudeContentItem::ToolResult { .. } => {
// handled via User or Assistant ToolResult messages below
}
}
}
}
ClaudeJson::User { message, .. } => {
for item in &message.content {
if let ClaudeContentItem::ToolResult {
tool_use_id,
content,
is_error,
} = item
&& let Some(info) =
processor.tool_map.get(tool_use_id).cloned()
{
let is_command = matches!(
info.tool_data,
ClaudeToolData::Bash { .. }
);
if is_command {
// For bash commands, attach result as CommandRun output where possible
let (r#type, value) = if content.is_string() {
(
crate::logs::ToolResultValueType::Markdown,
content.clone(),
)
} else {
(
crate::logs::ToolResultValueType::Json,
content.clone(),
)
};
// Prefer string content to be the output; otherwise JSON
let output = match r#type {
crate::logs::ToolResultValueType::Markdown => {
content.as_str().map(|s| s.to_string())
}
crate::logs::ToolResultValueType::Json => {
Some(content.to_string())
}
};
// Derive success from is_error when present
let exit_status = is_error.as_ref().map(|e| {
crate::logs::CommandExitStatus::Success {
success: !*e,
}
});
let entry = NormalizedEntry {
timestamp: None,
entry_type: NormalizedEntryType::ToolUse {
tool_name: info.tool_name.clone(),
action_type: ActionType::CommandRun {
command: info.content.clone(),
result: Some(
crate::logs::CommandRunResult {
exit_status,
output,
},
),
},
},
content: info.content.clone(),
metadata: None,
};
msg_store.push_patch(ConversationPatch::replace(
info.entry_index,
entry,
));
} else {
// Show args and results for NotebookEdit and MCP tools
let is_notebook = matches!(
info.tool_data,
ClaudeToolData::NotebookEdit { .. }
);
let tool_name =
info.tool_data.get_name().to_string();
let is_mcp = tool_name.starts_with("mcp__");
if is_notebook || is_mcp {
let (res_type, res_value) =
Self::normalize_claude_tool_result_value(
content,
);
// Arguments: prefer input for MCP unknown, else full struct
// Arguments: prefer `input` field if present, derived from tool_data
let args_to_show =
serde_json::to_value(&info.tool_data)
.ok()
.and_then(|v| {
serde_json::from_value::<
ClaudeToolWithInput,
>(
v
)
.ok()
})
.map(|w| w.input)
.unwrap_or(serde_json::Value::Null);
// Normalize MCP label
let label = if is_mcp {
let parts: Vec<&str> =
tool_name.split("__").collect();
if parts.len() >= 3 {
format!("mcp:{}:{}", parts[1], parts[2])
} else {
tool_name.clone()
}
} else {
tool_name.clone()
};
let entry = NormalizedEntry {
timestamp: None,
entry_type: NormalizedEntryType::ToolUse {
tool_name: label.clone(),
action_type: ActionType::Tool {
tool_name: label,
arguments: Some(args_to_show),
result: Some(
crate::logs::ToolResult {
r#type: res_type,
value: res_value,
},
),
},
},
content: info.content.clone(),
metadata: None,
};
msg_store.push_patch(
ConversationPatch::replace(
info.entry_index,
entry,
),
);
}
}
}
}
}
_ => {
// Convert to normalized entries and create patches for other kinds
for entry in processor
.to_normalized_entries(&claude_json, &worktree_path)
{
let patch_id = entry_index_provider.next();
let patch = ConversationPatch::add_normalized_entry(
patch_id, entry,
);
msg_store.push_patch(patch);
}
}
}
}
Err(_) => {
@@ -369,6 +600,44 @@ impl ClaudeLogProcessor {
}
}
/// Normalize Claude tool_result content to either Markdown string or parsed JSON.
/// - If content is a string that parses as JSON, return Json with parsed value.
/// - If content is a string (non-JSON), return Markdown with the raw string.
/// - If content is an array of { text: string }, join texts as Markdown.
/// - Otherwise return Json with the original value.
fn normalize_claude_tool_result_value(
content: &serde_json::Value,
) -> (crate::logs::ToolResultValueType, serde_json::Value) {
if let Some(s) = content.as_str() {
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(s) {
return (crate::logs::ToolResultValueType::Json, parsed);
}
return (
crate::logs::ToolResultValueType::Markdown,
serde_json::Value::String(s.to_string()),
);
}
if let Ok(items) = serde_json::from_value::<Vec<ClaudeToolResultTextItem>>(content.clone())
&& !items.is_empty()
{
let joined = items
.into_iter()
.map(|i| i.text)
.collect::<Vec<_>>()
.join("\n\n");
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&joined) {
return (crate::logs::ToolResultValueType::Json, parsed);
}
return (
crate::logs::ToolResultValueType::Markdown,
serde_json::Value::String(joined),
);
}
(crate::logs::ToolResultValueType::Json, content.clone())
}
/// Convert Claude content item to normalized entry
fn content_item_to_normalized_entry(
content_item: &ClaudeContentItem,
@@ -484,6 +753,7 @@ impl ClaudeLogProcessor {
}
ClaudeToolData::Bash { command, .. } => ActionType::CommandRun {
command: command.clone(),
result: None,
},
ClaudeToolData::Grep { pattern, .. } => ActionType::Search {
query: pattern.clone(),
@@ -507,9 +777,10 @@ impl ClaudeLogProcessor {
ClaudeToolData::ExitPlanMode { plan } => {
ActionType::PlanPresentation { plan: plan.clone() }
}
ClaudeToolData::NotebookEdit { notebook_path, .. } => ActionType::FileEdit {
path: make_path_relative(notebook_path, worktree_path),
changes: vec![],
ClaudeToolData::NotebookEdit { .. } => ActionType::Tool {
tool_name: "NotebookEdit".to_string(),
arguments: Some(serde_json::to_value(tool_data).unwrap_or(serde_json::Value::Null)),
result: None,
},
ClaudeToolData::TodoWrite { todos } => ActionType::TodoManagement {
todos: todos
@@ -528,9 +799,33 @@ impl ClaudeLogProcessor {
ClaudeToolData::LS { .. } => ActionType::Other {
description: "List directory".to_string(),
},
ClaudeToolData::Unknown { .. } => ActionType::Other {
description: format!("Tool: {}", tool_data.get_name()),
},
ClaudeToolData::Unknown { data } => {
// Surface MCP tools as generic Tool with args
let name = tool_data.get_name();
if name.starts_with("mcp__") {
let parts: Vec<&str> = name.split("__").collect();
let label = if parts.len() >= 3 {
format!("mcp:{}:{}", parts[1], parts[2])
} else {
name.to_string()
};
// Extract `input` if present by serializing then deserializing to a tiny struct
let args = serde_json::to_value(tool_data)
.ok()
.and_then(|v| serde_json::from_value::<ClaudeToolWithInput>(v).ok())
.map(|w| w.input)
.unwrap_or(serde_json::Value::Null);
ActionType::Tool {
tool_name: label,
arguments: Some(args),
result: None,
}
} else {
ActionType::Other {
description: format!("Tool: {}", tool_data.get_name()),
}
}
}
}
}
@@ -543,9 +838,25 @@ impl ClaudeLogProcessor {
match action_type {
ActionType::FileRead { path } => format!("`{path}`"),
ActionType::FileEdit { path, .. } => format!("`{path}`"),
ActionType::CommandRun { command } => format!("`{command}`"),
ActionType::CommandRun { command, .. } => format!("`{command}`"),
ActionType::Search { query } => format!("`{query}`"),
ActionType::WebFetch { url } => format!("`{url}`"),
ActionType::Tool { .. } => match tool_data {
ClaudeToolData::NotebookEdit { notebook_path, .. } => {
format!("`{}`", make_path_relative(notebook_path, worktree_path))
}
ClaudeToolData::Unknown { data } => {
let name = tool_data.get_name();
if name.starts_with("mcp__") {
let parts: Vec<&str> = name.split("__").collect();
if parts.len() >= 3 {
return format!("mcp:{}:{}", parts[1], parts[2]);
}
}
name.to_string()
}
_ => tool_data.get_name().to_string(),
},
ActionType::TaskCreate { description } => description.clone(),
ActionType::PlanPresentation { plan } => plan.clone(),
ActionType::TodoManagement { .. } => "TODO list updated".to_string(),
@@ -727,6 +1038,26 @@ pub enum ClaudeToolData {
},
}
// Helper structs for parsing tool_result content and generic tool input
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
struct ClaudeToolResultTextItem {
text: String,
}
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
struct ClaudeToolWithInput {
#[serde(default)]
input: serde_json::Value,
}
#[derive(Debug, Clone)]
struct ClaudeToolCallInfo {
entry_index: usize,
tool_name: String,
tool_data: ClaudeToolData,
content: String,
}
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
pub struct ClaudeTodoItem {
#[serde(default)]

View File

@@ -199,6 +199,15 @@ impl StandardCodingAgentExecutor for Codex {
let current_dir = current_dir.clone();
tokio::spawn(async move {
let mut stream = msg_store.stdout_lines_stream();
use std::collections::HashMap;
// Track exec call ids to entry index, tool_name, content, and command
let mut exec_info_map: HashMap<String, (usize, String, String, String)> =
HashMap::new();
// Track MCP calls to index, tool_name, args, and initial content
let mut mcp_info_map: HashMap<
String,
(usize, String, Option<serde_json::Value>, String),
> = HashMap::new();
while let Some(Ok(line)) = stream.next().await {
let trimmed = line.trim();
@@ -206,15 +215,195 @@ impl StandardCodingAgentExecutor for Codex {
continue;
}
if let Ok(entries) = serde_json::from_str::<CodexJson>(trimmed).map(|codex_json| {
codex_json
.to_normalized_entries(&current_dir)
.unwrap_or_default()
}) {
for entry in entries {
let new_id = entry_index_provider.next();
let patch = ConversationPatch::add_normalized_entry(new_id, entry);
msg_store.push_patch(patch);
if let Ok(cj) = serde_json::from_str::<CodexJson>(trimmed) {
// Handle result-carrying events that require replacement
match &cj {
CodexJson::StructuredMessage { msg, .. } => match msg {
CodexMsgContent::ExecCommandBegin {
call_id, command, ..
} => {
let command_str = command.join(" ");
let entry = NormalizedEntry {
timestamp: None,
entry_type: NormalizedEntryType::ToolUse {
tool_name: if command_str.contains("bash") {
"bash".to_string()
} else {
"shell".to_string()
},
action_type: ActionType::CommandRun {
command: command_str.clone(),
result: None,
},
},
content: format!("`{command_str}`"),
metadata: None,
};
let id = entry_index_provider.next();
if let Some(cid) = call_id.as_ref() {
let tool_name = if command_str.contains("bash") {
"bash".to_string()
} else {
"shell".to_string()
};
exec_info_map.insert(
cid.clone(),
(id, tool_name, entry.content.clone(), command_str.clone()),
);
}
msg_store
.push_patch(ConversationPatch::add_normalized_entry(id, entry));
}
CodexMsgContent::ExecCommandEnd {
call_id,
stdout,
stderr,
success,
exit_code,
} => {
if let Some(cid) = call_id.as_ref()
&& let Some((idx, tool_name, prev_content, prev_command)) =
exec_info_map.get(cid).cloned()
{
// Merge stdout and stderr for richer context
let output = match (stdout.as_ref(), stderr.as_ref()) {
(Some(sout), Some(serr)) => {
let sout_trim = sout.trim();
let serr_trim = serr.trim();
if sout_trim.is_empty() && serr_trim.is_empty() {
None
} else if sout_trim.is_empty() {
Some(serr.clone())
} else if serr_trim.is_empty() {
Some(sout.clone())
} else {
Some(format!(
"STDOUT:\n{sout_trim}\n\nSTDERR:\n{serr_trim}"
))
}
}
(Some(sout), None) => {
if sout.trim().is_empty() {
None
} else {
Some(sout.clone())
}
}
(None, Some(serr)) => {
if serr.trim().is_empty() {
None
} else {
Some(serr.clone())
}
}
(None, None) => None,
};
let exit_status = if let Some(s) = success {
Some(crate::logs::CommandExitStatus::Success {
success: *s,
})
} else {
exit_code.as_ref().map(|code| {
crate::logs::CommandExitStatus::ExitCode { code: *code }
})
};
let entry = NormalizedEntry {
timestamp: None,
entry_type: NormalizedEntryType::ToolUse {
tool_name,
action_type: ActionType::CommandRun {
command: prev_command,
result: Some(crate::logs::CommandRunResult {
exit_status,
output,
}),
},
},
content: prev_content,
metadata: None,
};
msg_store.push_patch(ConversationPatch::replace(idx, entry));
}
}
CodexMsgContent::McpToolCallBegin {
call_id,
invocation,
} => {
let tool_name =
format!("mcp:{}:{}", invocation.server, invocation.tool);
let content_str = invocation.tool.clone();
let entry = NormalizedEntry {
timestamp: None,
entry_type: NormalizedEntryType::ToolUse {
tool_name: tool_name.clone(),
action_type: ActionType::Tool {
tool_name: tool_name.clone(),
arguments: invocation.arguments.clone(),
result: None,
},
},
content: content_str.clone(),
metadata: None,
};
let id = entry_index_provider.next();
mcp_info_map.insert(
call_id.clone(),
(
id,
tool_name.clone(),
invocation.arguments.clone(),
content_str,
),
);
msg_store
.push_patch(ConversationPatch::add_normalized_entry(id, entry));
}
CodexMsgContent::McpToolCallEnd {
call_id, result, ..
} => {
if let Some((idx, tool_name, args, prev_content)) =
mcp_info_map.remove(call_id)
{
let entry = NormalizedEntry {
timestamp: None,
entry_type: NormalizedEntryType::ToolUse {
tool_name: tool_name.clone(),
action_type: ActionType::Tool {
tool_name,
arguments: args,
result: Some(crate::logs::ToolResult {
r#type: crate::logs::ToolResultValueType::Json,
value: result.clone(),
}),
},
},
content: prev_content,
metadata: None,
};
msg_store.push_patch(ConversationPatch::replace(idx, entry));
}
}
_ => {
if let Some(entries) = cj.to_normalized_entries(&current_dir) {
for entry in entries {
let new_id = entry_index_provider.next();
let patch =
ConversationPatch::add_normalized_entry(new_id, entry);
msg_store.push_patch(patch);
}
}
}
},
_ => {
if let Some(entries) = cj.to_normalized_entries(&current_dir) {
for entry in entries {
let new_id = entry_index_provider.next();
let patch =
ConversationPatch::add_normalized_entry(new_id, entry);
msg_store.push_patch(patch);
}
}
}
}
} else {
// Handle malformed JSON as raw output
@@ -327,6 +516,8 @@ pub enum CodexMsgContent {
stderr: Option<String>,
// Codex protocol has exit_code + duration; CLI may provide success; keep optional
success: Option<bool>,
#[serde(default)]
exit_code: Option<i32>,
},
#[serde(rename = "exec_approval_request")]
@@ -450,28 +641,7 @@ impl CodexJson {
metadata: None,
}])
}
CodexMsgContent::ExecCommandBegin { command, .. } => {
let command_str = command.join(" ");
// Map shell commands to tool names (following Claude pattern)
let tool_name = if command_str.contains("bash") {
"bash"
} else {
"shell"
};
Some(vec![NormalizedEntry {
timestamp: None,
entry_type: NormalizedEntryType::ToolUse {
tool_name: tool_name.to_string(),
action_type: ActionType::CommandRun {
command: command_str.clone(),
},
},
content: format!("`{command_str}`"),
metadata: None,
}])
}
CodexMsgContent::ExecCommandBegin { .. } => None,
CodexMsgContent::PatchApplyBegin { changes, .. } => {
let mut entries = Vec::new();
@@ -533,25 +703,7 @@ impl CodexJson {
Some(entries)
}
CodexMsgContent::McpToolCallBegin { invocation, .. } => {
let tool_name = format!("mcp_{}", invocation.tool);
let content = invocation.tool.clone();
Some(vec![NormalizedEntry {
timestamp: None,
entry_type: NormalizedEntryType::ToolUse {
tool_name,
action_type: ActionType::Other {
description: format!(
"MCP tool call to {} from {}",
invocation.tool, invocation.server
),
},
},
content,
metadata: None,
}])
}
CodexMsgContent::McpToolCallBegin { .. } => None,
CodexMsgContent::ExecApprovalRequest {
command,
cwd,
@@ -729,8 +881,8 @@ mod tests {
let entries = parse_test_json_lines(logs);
// Should have: agent_reasoning, exec_command_begin (task_started and task_complete skipped)
assert_eq!(entries.len(), 2);
// Should have only agent_reasoning (task_started, exec_command_begin, task_complete are skipped in to_normalized_entries)
assert_eq!(entries.len(), 1);
// Check agent reasoning (thinking)
assert!(matches!(
@@ -739,20 +891,7 @@ mod tests {
));
assert!(entries[0].content.contains("Inspecting the directory tree"));
// Check bash command
assert!(matches!(
entries[1].entry_type,
NormalizedEntryType::ToolUse { .. }
));
if let NormalizedEntryType::ToolUse {
tool_name,
action_type,
} = &entries[1].entry_type
{
assert_eq!(tool_name, "bash");
assert!(matches!(action_type, ActionType::CommandRun { .. }));
}
assert_eq!(entries[1].content, "`bash -lc ls -1`");
// Command entries are handled in the streaming path, not to_normalized_entries
}
#[test]
@@ -760,20 +899,15 @@ mod tests {
// Test shell command (not bash)
let shell_logs = r#"{"id":"1","msg":{"type":"exec_command_begin","call_id":"call_test","command":["sh","-c","echo hello"],"cwd":"/tmp"}}"#;
let entries = parse_test_json_lines(shell_logs);
assert_eq!(entries.len(), 1);
if let NormalizedEntryType::ToolUse { tool_name, .. } = &entries[0].entry_type {
assert_eq!(tool_name, "shell"); // Maps to shell, not bash
}
// to_normalized_entries skips exec_command_begin; mapping is tested in streaming path
assert_eq!(entries.len(), 0);
// Test bash command
let bash_logs = r#"{"id":"1","msg":{"type":"exec_command_begin","call_id":"call_test","command":["bash","-c","echo hello"],"cwd":"/tmp"}}"#;
let entries = parse_test_json_lines(bash_logs);
assert_eq!(entries.len(), 1);
assert_eq!(entries.len(), 0);
if let NormalizedEntryType::ToolUse { tool_name, .. } = &entries[0].entry_type {
assert_eq!(tool_name, "bash"); // Maps to bash
}
// Mapping to bash is exercised in the streaming path
}
#[test]
@@ -987,29 +1121,13 @@ invalid json line here
let entries = parse_test_json_lines(logs);
// Should have 2 entries (mcp_tool_call_begin and agent_message, mcp_tool_call_end skipped)
assert_eq!(entries.len(), 2);
// Check MCP tool call begin
// Should have only agent_message (mcp_tool_call_begin/end are skipped in to_normalized_entries)
assert_eq!(entries.len(), 1);
assert!(matches!(
entries[0].entry_type,
NormalizedEntryType::ToolUse { .. }
));
if let NormalizedEntryType::ToolUse {
tool_name,
action_type,
} = &entries[0].entry_type
{
assert_eq!(tool_name, "mcp_list_projects");
assert!(matches!(action_type, ActionType::Other { .. }));
}
// Check agent message
assert!(matches!(
entries[1].entry_type,
NormalizedEntryType::AssistantMessage
));
assert_eq!(entries[1].content, "Here are your projects");
assert_eq!(entries[0].content, "Here are your projects");
}
#[test]
@@ -1021,20 +1139,8 @@ invalid json line here
let entries = parse_test_json_lines(logs);
// Should have 2 entries (both mcp_tool_call_begin events, mcp_tool_call_end events skipped)
assert_eq!(entries.len(), 2);
// Check first MCP tool call
if let NormalizedEntryType::ToolUse { tool_name, .. } = &entries[0].entry_type {
assert_eq!(tool_name, "mcp_create_task");
}
assert!(entries[0].content.contains("create_task"));
// Check second MCP tool call
if let NormalizedEntryType::ToolUse { tool_name, .. } = &entries[1].entry_type {
assert_eq!(tool_name, "mcp_list_tasks");
}
assert!(entries[1].content.contains("list_tasks"));
// to_normalized_entries skips mcp_tool_call_begin/end; expect none
assert_eq!(entries.len(), 0);
}
#[test]

View File

@@ -128,6 +128,10 @@ impl StandardCodingAgentExecutor for Cursor {
let worktree_str = current_dir.to_string_lossy().to_string();
use std::collections::HashMap;
// Track tool call_id -> entry index
let mut call_index_map: HashMap<String, usize> = HashMap::new();
while let Some(Ok(line)) = lines.next().await {
// Parse line as CursorJson
let cursor_json: CursorJson = match serde_json::from_str(&line) {
@@ -208,7 +212,10 @@ impl StandardCodingAgentExecutor for Cursor {
}
CursorJson::ToolCall {
subtype, tool_call, ..
subtype,
call_id,
tool_call,
..
} => {
// Only process "started" subtype (completed contains results we currently ignore)
if subtype
@@ -230,8 +237,125 @@ impl StandardCodingAgentExecutor for Cursor {
metadata: None,
};
let id = entry_index_provider.next();
if let Some(cid) = call_id.as_ref() {
call_index_map.insert(cid.clone(), id);
}
msg_store
.push_patch(ConversationPatch::add_normalized_entry(id, entry));
} else if subtype
.as_deref()
.map(|s| s.eq_ignore_ascii_case("completed"))
.unwrap_or(false)
&& let Some(cid) = call_id.as_ref()
&& let Some(&idx) = call_index_map.get(cid)
{
// Compute base content and action again
let (mut new_action, content_str) =
tool_call.to_action_and_content(&worktree_str);
if let CursorToolCall::Shell { args, result } = &tool_call {
// Merge stdout/stderr and derive exit status when available using typed deserialization
let (stdout_val, stderr_val, exit_code) = if let Some(res) = result
{
match serde_json::from_value::<CursorShellResult>(res.clone()) {
Ok(r) => {
if let Some(out) = r.into_outcome() {
(out.stdout, out.stderr, out.exit_code)
} else {
(None, None, None)
}
}
Err(_) => (None, None, None),
}
} else {
(None, None, None)
};
let output = match (stdout_val, stderr_val) {
(Some(sout), Some(serr)) => {
let st = sout.trim();
let se = serr.trim();
if st.is_empty() && se.is_empty() {
None
} else if st.is_empty() {
Some(serr)
} else if se.is_empty() {
Some(sout)
} else {
Some(format!("STDOUT:\n{st}\n\nSTDERR:\n{se}"))
}
}
(Some(sout), None) => {
if sout.trim().is_empty() {
None
} else {
Some(sout)
}
}
(None, Some(serr)) => {
if serr.trim().is_empty() {
None
} else {
Some(serr)
}
}
(None, None) => None,
};
let exit_status = exit_code
.map(|code| crate::logs::CommandExitStatus::ExitCode { code });
new_action = ActionType::CommandRun {
command: args.command.clone(),
result: Some(crate::logs::CommandRunResult {
exit_status,
output,
}),
};
} else if let CursorToolCall::Mcp { args, result } = &tool_call {
// Extract a human-readable text from content array using typed deserialization
let md: Option<String> = if let Some(res) = result {
match serde_json::from_value::<CursorMcpResult>(res.clone()) {
Ok(r) => r.into_markdown(),
Err(_) => None,
}
} else {
None
};
let provider = args.provider_identifier.as_deref().unwrap_or("mcp");
let tname = args.tool_name.as_deref().unwrap_or(&args.name);
let label = format!("mcp:{provider}:{tname}");
new_action = ActionType::Tool {
tool_name: label.clone(),
arguments: Some(serde_json::json!({
"name": args.name,
"args": args.args,
"providerIdentifier": args.provider_identifier,
"toolName": args.tool_name,
})),
result: md.map(|s| crate::logs::ToolResult {
r#type: crate::logs::ToolResultValueType::Markdown,
value: serde_json::Value::String(s),
}),
};
}
let entry = NormalizedEntry {
timestamp: None,
entry_type: NormalizedEntryType::ToolUse {
tool_name: match &tool_call {
CursorToolCall::Mcp { args, .. } => {
let provider = args
.provider_identifier
.as_deref()
.unwrap_or("mcp");
let tname =
args.tool_name.as_deref().unwrap_or(&args.name);
format!("mcp:{provider}:{tname}")
}
_ => tool_call.get_name().to_string(),
},
action_type: new_action,
},
content: content_str,
metadata: None,
};
msg_store.push_patch(ConversationPatch::replace(idx, entry));
}
}
@@ -441,6 +565,12 @@ pub enum CursorToolCall {
#[serde(default)]
result: Option<serde_json::Value>,
},
#[serde(rename = "mcpToolCall")]
Mcp {
args: CursorMcpArgs,
#[serde(default)]
result: Option<serde_json::Value>,
},
/// Generic fallback for unknown tools (amp.rs pattern)
#[serde(untagged)]
Unknown {
@@ -461,6 +591,7 @@ impl CursorToolCall {
CursorToolCall::Edit { .. } => "edit",
CursorToolCall::Delete { .. } => "delete",
CursorToolCall::Todo { .. } => "todo",
CursorToolCall::Mcp { .. } => "mcp",
CursorToolCall::Unknown { data } => {
data.keys().next().map(|s| s.as_str()).unwrap_or("unknown")
}
@@ -544,6 +675,7 @@ impl CursorToolCall {
(
ActionType::CommandRun {
command: cmd.clone(),
result: None,
},
format!("`{cmd}`"),
)
@@ -614,6 +746,30 @@ impl CursorToolCall {
"TODO list updated".to_string(),
)
}
CursorToolCall::Mcp { args, .. } => {
let provider = args.provider_identifier.as_deref().unwrap_or("mcp");
let tool_name = args.tool_name.as_deref().unwrap_or(&args.name);
let label = format!("mcp:{provider}:{tool_name}");
let summary = tool_name.to_string();
let mut arguments = serde_json::json!({
"name": args.name,
"args": args.args,
});
if let Some(p) = &args.provider_identifier {
arguments["providerIdentifier"] = serde_json::Value::String(p.clone());
}
if let Some(tn) = &args.tool_name {
arguments["toolName"] = serde_json::Value::String(tn.clone());
}
(
ActionType::Tool {
tool_name: label,
arguments: Some(arguments),
result: None,
},
summary,
)
}
CursorToolCall::Unknown { .. } => (
ActionType::Other {
description: format!("Tool: {}", self.get_name()),
@@ -624,6 +780,104 @@ impl CursorToolCall {
}
}
/* ===========================
Typed tool results for Cursor
=========================== */
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
pub struct CursorShellOutcome {
#[serde(default)]
pub stdout: Option<String>,
#[serde(default)]
pub stderr: Option<String>,
#[serde(default, rename = "exitCode")]
pub exit_code: Option<i32>,
}
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
pub struct CursorShellWrappedResult {
#[serde(default)]
pub success: Option<CursorShellOutcome>,
#[serde(default)]
pub failure: Option<CursorShellOutcome>,
}
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
#[serde(untagged)]
pub enum CursorShellResult {
Wrapped(CursorShellWrappedResult),
Flat(CursorShellOutcome),
Unknown(serde_json::Value),
}
impl CursorShellResult {
pub fn into_outcome(self) -> Option<CursorShellOutcome> {
match self {
CursorShellResult::Flat(o) => Some(o),
CursorShellResult::Wrapped(w) => w.success.or(w.failure),
CursorShellResult::Unknown(_) => None,
}
}
}
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
pub struct CursorMcpTextInner {
pub text: String,
}
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
pub struct CursorMcpContentItem {
#[serde(default)]
pub text: Option<CursorMcpTextInner>,
}
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
pub struct CursorMcpOutcome {
#[serde(default)]
pub content: Option<Vec<CursorMcpContentItem>>,
#[serde(default, rename = "isError")]
pub is_error: Option<bool>,
}
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
pub struct CursorMcpWrappedResult {
#[serde(default)]
pub success: Option<CursorMcpOutcome>,
#[serde(default)]
pub failure: Option<CursorMcpOutcome>,
}
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
#[serde(untagged)]
pub enum CursorMcpResult {
Wrapped(CursorMcpWrappedResult),
Flat(CursorMcpOutcome),
Unknown(serde_json::Value),
}
impl CursorMcpResult {
pub fn into_markdown(self) -> Option<String> {
let outcome = match self {
CursorMcpResult::Flat(o) => Some(o),
CursorMcpResult::Wrapped(w) => w.success.or(w.failure),
CursorMcpResult::Unknown(_) => None,
}?;
let items = outcome.content.unwrap_or_default();
let mut parts: Vec<String> = Vec::new();
for item in items {
if let Some(t) = item.text {
parts.push(t.text);
}
}
if parts.is_empty() {
None
} else {
Some(parts.join("\n\n"))
}
}
}
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
pub struct CursorShellArgs {
pub command: String,
@@ -744,6 +998,17 @@ pub struct CursorUpdateTodosArgs {
pub todos: Option<Vec<CursorTodoItem>>,
}
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
pub struct CursorMcpArgs {
pub name: String,
#[serde(default)]
pub args: serde_json::Value,
#[serde(default, alias = "providerIdentifier")]
pub provider_identifier: Option<String>,
#[serde(default, alias = "toolName")]
pub tool_name: Option<String>,
}
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
pub struct CursorTodoItem {
#[serde(default)]

View File

@@ -717,6 +717,7 @@ impl ToolUtils {
}
Tool::Bash { command, .. } => ActionType::CommandRun {
command: command.clone(),
result: None,
},
Tool::Grep { pattern, .. } => ActionType::Search {
query: pattern.clone(),

View File

@@ -5,6 +5,37 @@ pub mod plain_text_processor;
pub mod stderr_processor;
pub mod utils;
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
#[ts(export)]
pub enum ToolResultValueType {
Markdown,
Json,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct ToolResult {
pub r#type: ToolResultValueType,
/// For Markdown, this will be a JSON string; for JSON, a structured value
pub value: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
#[ts(export)]
pub enum CommandExitStatus {
ExitCode { code: i32 },
Success { success: bool },
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct CommandRunResult {
pub exit_status: Option<CommandExitStatus>,
pub output: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
pub struct NormalizedConversation {
pub entries: Vec<NormalizedEntry>,
@@ -60,6 +91,8 @@ pub enum ActionType {
},
CommandRun {
command: String,
#[serde(default)]
result: Option<CommandRunResult>,
},
Search {
query: String,
@@ -67,6 +100,14 @@ pub enum ActionType {
WebFetch {
url: String,
},
/// Generic tool with optional arguments and result for rich rendering
Tool {
tool_name: String,
#[serde(default)]
arguments: Option<serde_json::Value>,
#[serde(default)]
result: Option<ToolResult>,
},
TaskCreate {
description: String,
},

View File

@@ -86,12 +86,16 @@ fn generate_types_content() -> String {
services::services::events::EventPatch::decl(),
services::services::events::EventPatchInner::decl(),
services::services::events::RecordTypes::decl(),
executors::logs::CommandExitStatus::decl(),
executors::logs::CommandRunResult::decl(),
executors::logs::NormalizedConversation::decl(),
executors::logs::NormalizedEntry::decl(),
executors::logs::NormalizedEntryType::decl(),
executors::logs::FileChange::decl(),
executors::logs::ActionType::decl(),
executors::logs::TodoItem::decl(),
executors::logs::ToolResult::decl(),
executors::logs::ToolResultValueType::decl(),
executors::logs::utils::patch::PatchType::decl(),
serde_json::Value::decl(),
];

View File

@@ -21,6 +21,8 @@ import {
type ActionType,
} from 'shared/types.ts';
import FileChangeRenderer from './FileChangeRenderer';
import ToolDetails from './ToolDetails';
import { Braces, FileText, MoreHorizontal, Dot } from 'lucide-react';
type Props = {
entry: NormalizedEntry;
@@ -149,6 +151,49 @@ function DisplayConversationEntry({ entry, expansionKey }: Props) {
>)
: null;
// One-line collapsed UX for tool entries
const isToolUse = entry.entry_type.type === 'tool_use';
const toolAction: any = isToolUse
? (entry.entry_type as any).action_type
: null;
const hasArgs = toolAction?.action === 'tool' && !!toolAction?.arguments;
const hasResult = toolAction?.action === 'tool' && !!toolAction?.result;
const isCommand = toolAction?.action === 'command_run';
const commandOutput: string | null = isCommand
? (toolAction?.result?.output ?? null)
: null;
// Derive success from either { type: 'success', success: boolean } or { type: 'exit_code', code: number }
let commandSuccess: boolean | undefined = undefined;
let commandExitCode: number | undefined = undefined;
if (isCommand) {
const st: any = toolAction?.result?.exit_status;
if (st && typeof st === 'object') {
if (st.type === 'success' && typeof st.success === 'boolean') {
commandSuccess = st.success;
} else if (st.type === 'exit_code' && typeof st.code === 'number') {
commandExitCode = st.code;
commandSuccess = st.code === 0;
}
}
}
const outputMeta = (() => {
if (!commandOutput) return null;
const lineCount =
commandOutput === '' ? 0 : commandOutput.split('\n').length;
const bytes = new Blob([commandOutput]).size;
const kb = bytes / 1024;
const sizeStr = kb >= 1 ? `${kb.toFixed(1)} kB` : `${bytes} B`;
return { lineCount, sizeStr };
})();
const canExpand =
(isCommand && !!commandOutput) ||
(toolAction?.action === 'tool' && (hasArgs || hasResult));
const [toolExpanded, toggleToolExpanded] = useExpandable(
`tool-entry:${expansionKey}`,
false
);
return (
<div className="px-4 py-1">
<div className="flex items-start gap-3">
@@ -201,14 +246,149 @@ function DisplayConversationEntry({ entry, expansionKey }: Props) {
)}
</div>
) : (
<div className={getContentClassName(entry.entry_type)}>
{shouldRenderMarkdown(entry.entry_type) ? (
<MarkdownRenderer
content={entry.content}
className="whitespace-pre-wrap break-words"
/>
<div>
{isToolUse ? (
canExpand ? (
<button
onClick={() => toggleToolExpanded()}
className="flex items-center gap-2 w-full text-left"
title={toolExpanded ? 'Hide details' : 'Show details'}
>
<span className="flex items-center gap-1 min-w-0">
<span
className="text-sm truncate whitespace-nowrap overflow-hidden text-ellipsis"
title={entry.content}
>
{shouldRenderMarkdown(entry.entry_type) ? (
<MarkdownRenderer
content={entry.content}
className="inline"
/>
) : (
entry.content
)}
</span>
{/* Icons immediately after tool name */}
{isCommand ? (
<>
{typeof commandSuccess === 'boolean' && (
<span
className={
'px-1.5 py-0.5 rounded text-[10px] border ' +
(commandSuccess
? 'bg-green-50 text-green-700 border-green-200 dark:bg-green-900/20 dark:text-green-300 dark:border-green-900/40'
: 'bg-red-50 text-red-700 border-red-200 dark:bg-red-900/20 dark:text-red-300 dark:border-red-900/40')
}
title={
typeof commandExitCode === 'number'
? `exit code: ${commandExitCode}`
: commandSuccess
? 'success'
: 'failed'
}
>
{typeof commandExitCode === 'number'
? `exit ${commandExitCode}`
: commandSuccess
? 'ok'
: 'fail'}
</span>
)}
{commandOutput && (
<span
title={
outputMeta
? `output: ${outputMeta.lineCount} lines · ${outputMeta.sizeStr}`
: 'output'
}
>
<FileText className="h-3.5 w-3.5 text-zinc-500" />
</span>
)}
</>
) : (
<>
{hasArgs && (
<Braces className="h-3.5 w-3.5 text-zinc-500" />
)}
{hasResult &&
(toolAction?.result?.type === 'json' ? (
<Braces className="h-3.5 w-3.5 text-zinc-500" />
) : (
<FileText className="h-3.5 w-3.5 text-zinc-500" />
))}
</>
)}
</span>
<MoreHorizontal className="ml-auto h-4 w-4 text-zinc-400 group-hover:text-zinc-600" />
</button>
) : (
<div className="flex items-center gap-2">
<div
className={
'text-sm truncate whitespace-nowrap overflow-hidden text-ellipsis'
}
title={entry.content}
>
{shouldRenderMarkdown(entry.entry_type) ? (
<MarkdownRenderer
content={entry.content}
className="inline"
/>
) : (
entry.content
)}
</div>
{isCommand ? (
<>
{typeof commandSuccess === 'boolean' && (
<Dot
className={
'h-4 w-4 ' +
(commandSuccess
? 'text-green-600'
: 'text-red-600')
}
/>
)}
{commandOutput && (
<span
title={
outputMeta
? `output: ${outputMeta.lineCount} lines · ${outputMeta.sizeStr}`
: 'output'
}
>
<FileText className="h-3.5 w-3.5 text-zinc-500" />
</span>
)}
</>
) : (
<>
{hasArgs && (
<Braces className="h-3.5 w-3.5 text-zinc-500" />
)}
{hasResult &&
(toolAction?.result?.type === 'json' ? (
<Braces className="h-3.5 w-3.5 text-zinc-500" />
) : (
<FileText className="h-3.5 w-3.5 text-zinc-500" />
))}
</>
)}
</div>
)
) : (
entry.content
<div className={getContentClassName(entry.entry_type)}>
{shouldRenderMarkdown(entry.entry_type) ? (
<MarkdownRenderer
content={entry.content}
className="whitespace-pre-wrap break-words"
/>
) : (
entry.content
)}
</div>
)}
</div>
)}
@@ -225,6 +405,34 @@ function DisplayConversationEntry({ entry, expansionKey }: Props) {
/>
);
})}
{entry.entry_type.type === 'tool_use' &&
toolExpanded &&
(() => {
const at: any = entry.entry_type.action_type as any;
if (at?.action === 'tool') {
return (
<ToolDetails
arguments={at.arguments ?? null}
result={
at.result
? { type: at.result.type, value: at.result.value }
: null
}
/>
);
}
if (at?.action === 'command_run') {
const output = at?.result?.output as string | undefined;
const exit = (at?.result?.exit_status as any) ?? null;
return (
<ToolDetails
commandOutput={output ?? null}
commandExit={exit}
/>
);
}
return null;
})()}
</div>
</div>
</div>

View File

@@ -0,0 +1,93 @@
import MarkdownRenderer from '@/components/ui/markdown-renderer.tsx';
import RawLogText from '@/components/common/RawLogText';
import { Braces, FileText } from 'lucide-react';
type JsonValue = any;
type ToolResult = {
type: 'markdown' | 'json';
value: JsonValue;
};
type Props = {
arguments?: JsonValue | null;
result?: ToolResult | null;
commandOutput?: string | null;
commandExit?:
| { type: 'success'; success: boolean }
| { type: 'exit_code'; code: number }
| null;
};
export default function ToolDetails({
arguments: args,
result,
commandOutput,
commandExit,
}: Props) {
const renderJson = (v: JsonValue) => (
<pre className="mt-1 max-h-80 overflow-auto rounded bg-muted p-2 text-xs">
{JSON.stringify(v, null, 2)}
</pre>
);
return (
<div className="mt-2 space-y-3">
{args && (
<section>
<div className="flex items-center gap-2 text-xs text-zinc-500">
<Braces className="h-3 w-3" />
<span>Arguments</span>
</div>
{renderJson(args)}
</section>
)}
{result && (
<section>
<div className="flex items-center gap-2 text-xs text-zinc-500">
{result.type === 'json' ? (
<Braces className="h-3 w-3" />
) : (
<FileText className="h-3 w-3" />
)}
<span>Result</span>
</div>
<div className="mt-1">
{result.type === 'markdown' ? (
<MarkdownRenderer content={String(result.value ?? '')} />
) : (
renderJson(result.value)
)}
</div>
</section>
)}
{(commandOutput || commandExit) && (
<section>
<div className="flex items-center gap-2 text-xs text-zinc-500">
<FileText className="h-3 w-3" />
<span>
Output
{commandExit && (
<>
{' '}
<span className="ml-1 px-1.5 py-0.5 rounded bg-zinc-100 dark:bg-zinc-800 text-[10px] text-zinc-600 dark:text-zinc-300 border border-zinc-200/80 dark:border-zinc-700/80">
{commandExit.type === 'exit_code'
? `exit ${commandExit.code}`
: commandExit.success
? 'ok'
: 'fail'}
</span>
</>
)}
</span>
</div>
{commandOutput && (
<div className="mt-1">
<RawLogText content={commandOutput} />
</div>
)}
</section>
)}
</div>
);
}

View File

@@ -186,6 +186,10 @@ export type EventPatchInner = { db_op: string, record: RecordTypes, };
export type RecordTypes = { "type": "TASK", "data": Task } | { "type": "TASK_ATTEMPT", "data": TaskAttempt } | { "type": "EXECUTION_PROCESS", "data": ExecutionProcess } | { "type": "DELETED_TASK", "data": { rowid: bigint, } } | { "type": "DELETED_TASK_ATTEMPT", "data": { rowid: bigint, } } | { "type": "DELETED_EXECUTION_PROCESS", "data": { rowid: bigint, } };
export type CommandExitStatus = { "type": "exit_code", code: number, } | { "type": "success", success: boolean, };
export type CommandRunResult = { exit_status: CommandExitStatus | null, output: string | null, };
export type NormalizedConversation = { entries: Array<NormalizedEntry>, session_id: string | null, executor_type: string, prompt: string | null, summary: string | null, };
export type NormalizedEntry = { timestamp: string | null, entry_type: NormalizedEntryType, content: string, };
@@ -202,10 +206,18 @@ unified_diff: string,
*/
has_line_numbers: boolean, };
export type ActionType = { "action": "file_read", path: string, } | { "action": "file_edit", path: string, changes: Array<FileChange>, } | { "action": "command_run", command: string, } | { "action": "search", query: string, } | { "action": "web_fetch", url: string, } | { "action": "task_create", description: string, } | { "action": "plan_presentation", plan: string, } | { "action": "todo_management", todos: Array<TodoItem>, operation: string, } | { "action": "other", description: string, };
export type ActionType = { "action": "file_read", path: string, } | { "action": "file_edit", path: string, changes: Array<FileChange>, } | { "action": "command_run", command: string, result: CommandRunResult | null, } | { "action": "search", query: string, } | { "action": "web_fetch", url: string, } | { "action": "tool", tool_name: string, arguments: JsonValue | null, result: ToolResult | null, } | { "action": "task_create", description: string, } | { "action": "plan_presentation", plan: string, } | { "action": "todo_management", todos: Array<TodoItem>, operation: string, } | { "action": "other", description: string, };
export type TodoItem = { content: string, status: string, priority: string | null, };
export type ToolResult = { type: ToolResultValueType,
/**
* For Markdown, this will be a JSON string; for JSON, a structured value
*/
value: JsonValue, };
export type ToolResultValueType = { "type": "markdown" } | { "type": "json" };
export type PatchType = { "type": "NORMALIZED_ENTRY", "content": NormalizedEntry } | { "type": "STDOUT", "content": string } | { "type": "STDERR", "content": string } | { "type": "DIFF", "content": Diff };
export type JsonValue = number | string | boolean | Array<JsonValue> | { [key in string]?: JsonValue } | null;