Display tool call arguments and results for misc/mcp tools and bash commands (#563)
This commit is contained in:
@@ -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(&_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,
|
||||
¤t_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,
|
||||
¤t_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,
|
||||
¤t_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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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(¤t_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(¤t_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(¤t_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]
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -717,6 +717,7 @@ impl ToolUtils {
|
||||
}
|
||||
Tool::Bash { command, .. } => ActionType::CommandRun {
|
||||
command: command.clone(),
|
||||
result: None,
|
||||
},
|
||||
Tool::Grep { pattern, .. } => ActionType::Search {
|
||||
query: pattern.clone(),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user