Normalize shell and patch tool cards

This commit is contained in:
iris committed 2026-09-09 20:30:52 -04:00
1 parent 4dc3e3d784
commit b507656abd
11 files changed
+327 -16

No files matched your search

+159 -11
View File
@@ -6,7 +6,7 @@
use serde_json::{Value, json};
use super::super::driver::{Event, SessionStatus};
use super::super::driver::{Event, SessionStatus, patch_start};
#[derive(Default)]
pub(super) struct Translator {
@@ -150,6 +150,15 @@ impl Translator {
}
fn start_item(item: &Value) -> Vec<Event> {
if matches!(
item.get("type").and_then(Value::as_str),
Some("file_change" | "fileChange")
) {
let Some(id) = item.get("id").and_then(Value::as_str) else {
return Vec::new();
};
return vec![patch_start(id.to_string(), file_change_diff(item))];
}
let Some((id, tool, input)) = tool(item) else {
return Vec::new();
};
@@ -189,6 +198,16 @@ fn complete_item(item: &Value, include_agent_message: bool) -> Vec<Event> {
})
.unwrap_or_default(),
Some("reasoning" | "userMessage") => Vec::new(),
Some("file_change" | "fileChange") => item
.get("id")
.and_then(Value::as_str)
.map(|id| {
vec![Event::ToolEnd {
id: id.to_string(),
output: tool_output(item),
}]
})
.unwrap_or_default(),
_ => {
let Some((id, _, _)) = tool(item) else {
return Vec::new();
@@ -203,14 +222,8 @@ fn tool(item: &Value) -> Option<(String, String, Value)> {
let id = item.get("id")?.as_str()?.to_string();
let kind = item.get("type")?.as_str()?;
let (name, input) = match kind {
"command_execution" | "commandExecution" => (
"exec_command".to_string(),
json!({"command": item.get("command").cloned().unwrap_or(Value::Null)}),
),
"file_change" | "fileChange" => (
"apply_patch".to_string(),
item.get("changes").cloned().unwrap_or(Value::Null),
),
"command_execution" | "commandExecution" => command_tool(item),
"file_change" | "fileChange" => return None,
"mcp_tool_call" | "mcpToolCall" => (
item.get("tool")
.or_else(|| item.get("name"))
@@ -247,8 +260,85 @@ fn tool(item: &Value) -> Option<(String, String, Value)> {
Some((id, name, input))
}
/// Codex records the executor's argv, while Claude reports the script handed to its Bash tool.
/// Collapse Codex's standard wrapper to the same common shape so the transcript describes the
/// command a person wrote, not the implementation used to start it. An unfamiliar executable is
/// left intact: hiding that would make a deliberately selected shell look like Bash.
fn command_tool(item: &Value) -> (String, Value) {
let command = item.get("command").cloned().unwrap_or(Value::Null);
if let Some(script) = command.as_str() {
return ("Bash".to_string(), json!({"command": script}));
}
if let Some(argv) = command.as_array()
&& let [program, option, script] = argv.as_slice()
&& program
.as_str()
.and_then(|program| program.rsplit('/').next())
== Some("bash")
&& matches!(option.as_str(), Some("-c" | "-lc"))
&& let Some(script) = script.as_str()
{
return ("Bash".to_string(), json!({"command": script}));
}
let command = command
.as_array()
.map(|argv| argv.iter().map(shell_word).collect::<Vec<_>>().join(" "))
.unwrap_or_else(|| command.to_string());
("Shell".to_string(), json!({"command": command}))
}
fn shell_word(word: &Value) -> String {
let Some(word) = word.as_str() else {
return word.to_string();
};
if !word.is_empty()
&& word
.chars()
.all(|character| character.is_ascii_alphanumeric() || "/_-.=:,@+".contains(character))
{
return word.to_string();
}
format!("'{}'", word.replace('\'', "'\\''"))
}
fn file_change_diff(item: &Value) -> String {
let Some(changes) = item.get("changes").and_then(Value::as_object) else {
return String::new();
};
changes
.iter()
.map(|(path, change)| {
let kind = change.get("type").and_then(Value::as_str);
let from = if kind == Some("add") {
"/dev/null"
} else {
path
};
let to = if kind == Some("delete") {
"/dev/null"
} else {
change
.get("move_path")
.and_then(Value::as_str)
.unwrap_or(path)
};
let body = change
.get("unified_diff")
.and_then(Value::as_str)
.unwrap_or_default();
format!("--- {from}\n+++ {to}\n{body}")
})
.collect::<Vec<_>>()
.join("\n")
}
fn tool_output(item: &Value) -> String {
match item.get("type").and_then(Value::as_str) {
Some("file_change" | "fileChange")
if item.get("status").and_then(Value::as_str) == Some("completed") =>
{
return String::new();
}
Some("dynamicToolCall") => {
return content_text(item.get("contentItems"), "inputText");
}
@@ -376,9 +466,16 @@ mod tests {
fn translates_tools_and_limits_without_matching_whole_records() {
let mut translator = Translator::default();
let started = translator.translate(&line(
r#"{"type":"item.started","item":{"id":"item_1","type":"command_execution","command":"pwd","status":"in_progress"}}"#,
r#"{"type":"item.started","item":{"id":"item_1","type":"command_execution","command":["/usr/bin/bash","-lc","pwd"],"status":"in_progress"}}"#,
));
assert!(matches!(&started[0], Event::ToolStart { tool, .. } if tool == "exec_command"));
assert_eq!(
started,
vec![Event::ToolStart {
id: "item_1".to_string(),
tool: "Bash".to_string(),
input: json!({"command": "pwd"})
}]
);
let ended = translator.translate(&line(
r#"{"type":"item.completed","item":{"id":"item_1","type":"command_execution","command":"pwd","aggregated_output":"/tmp\n","exit_code":0,"status":"completed"}}"#,
));
@@ -401,6 +498,57 @@ mod tests {
assert!(translator.limited());
}
#[test]
fn command_translation_only_hides_the_known_bash_wrapper() {
let legacy = tool(&line(
r#"{"id":"old","type":"command_execution","command":"pwd"}"#,
));
assert_eq!(
legacy,
Some((
"old".to_string(),
"Bash".to_string(),
json!({"command": "pwd"})
))
);
let fish = tool(&line(
r#"{"id":"fish","type":"commandExecution","command":["/usr/bin/fish","-c","pwd"]}"#,
));
assert_eq!(
fish,
Some((
"fish".to_string(),
"Shell".to_string(),
json!({"command": "/usr/bin/fish -c pwd"})
))
);
}
#[test]
fn a_file_change_becomes_the_common_patch_shape() {
let mut translator = Translator::default();
let started = translator.translate(&line(
r#"{"method":"item/started","params":{"item":{"id":"patch-1","type":"fileChange","changes":{"src/main.rs":{"type":"update","unified_diff":"@@ -1 +1 @@\n-old\n+new\n","move_path":null}},"status":"inProgress"}}}"#,
));
assert_eq!(
started,
vec![patch_start(
"patch-1".to_string(),
"--- src/main.rs\n+++ src/main.rs\n@@ -1 +1 @@\n-old\n+new\n".to_string()
)]
);
assert_eq!(
translator.translate(&line(
r#"{"method":"item/completed","params":{"item":{"id":"patch-1","type":"fileChange","changes":{},"status":"completed","stdout":"Success"}}}"#
)),
vec![Event::ToolEnd {
id: "patch-1".to_string(),
output: String::new()
}]
);
}
#[test]
fn translates_native_app_server_streaming_without_repeating_the_final_item() {
let mut translator = Translator::default();