Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions nori-rs/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions nori-rs/acp/src/connection/sacp_connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -262,8 +262,9 @@ impl SacpConnection {
connection: ConnectionTo<Agent>| {
// Translate ACP permission request to Codex approval event.
let event = if let Some(patch_event) =
translator::permission_request_to_patch_approval_event(&request)
{
translator::permission_request_to_patch_approval_event(
&request, &cwd,
) {
ApprovalEventType::Patch(patch_event)
} else {
let exec_event = translator::permission_request_to_approval_event(
Expand Down
103 changes: 93 additions & 10 deletions nori-rs/acp/src/translator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -705,6 +705,7 @@ pub fn is_patch_operation(
pub fn tool_call_to_file_change(
kind: Option<&acp::ToolKind>,
raw_input: Option<&serde_json::Value>,
cwd: &std::path::Path,
) -> Option<(PathBuf, FileChange)> {
let input = raw_input?;
let file_path = extract_file_path(Some(input))?;
Expand Down Expand Up @@ -737,8 +738,9 @@ pub fn tool_call_to_file_change(
let old_string = input.get("old_string").and_then(|v| v.as_str())?;
let new_string = input.get("new_string").and_then(|v| v.as_str())?;

// Generate unified diff using diffy
let unified_diff = diffy::create_patch(old_string, new_string).to_string();
// Generate unified diff using diffy with file context if available
let unified_diff =
codex_core::util::create_patch_with_context(&path, cwd, old_string, new_string);

Some((
path,
Expand All @@ -755,6 +757,7 @@ pub fn tool_call_to_file_change(
/// patch approval UI in the TUI instead of the generic exec approval.
pub fn permission_request_to_patch_approval_event(
request: &acp::RequestPermissionRequest,
cwd: &std::path::Path,
) -> Option<ApplyPatchApprovalRequestEvent> {
let kind = request.tool_call.fields.kind.as_ref();
let raw_input = request.tool_call.fields.raw_input.as_ref();
Expand All @@ -764,7 +767,7 @@ pub fn permission_request_to_patch_approval_event(
return None;
}

let (path, change) = tool_call_to_file_change(kind, raw_input)?;
let (path, change) = tool_call_to_file_change(kind, raw_input, cwd)?;

let mut changes = HashMap::new();
changes.insert(path, change);
Expand Down Expand Up @@ -1237,7 +1240,11 @@ mod tests {
"new_string": "fn new() {\n println!(\"hello\");\n}"
});

let result = tool_call_to_file_change(Some(&acp::ToolKind::Edit), Some(&input));
let result = tool_call_to_file_change(
Some(&acp::ToolKind::Edit),
Some(&input),
std::path::Path::new("."),
);
assert!(result.is_some());

let (path, change) = result.unwrap();
Expand All @@ -1258,14 +1265,82 @@ mod tests {
}
}

#[test]
fn test_tool_call_to_file_change_edit_with_context() {
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("test.txt");
let content = "line 1\nline 2\nline 3\nline 4\nline 5\n";
std::fs::write(&file_path, content).unwrap();

let input = serde_json::json!({
"path": file_path.to_str().unwrap(),
"old_string": "line 3\n",
"new_string": "line 3 modified\n"
});

// Use the temp dir as cwd
let result =
tool_call_to_file_change(Some(&acp::ToolKind::Edit), Some(&input), temp_dir.path());
assert!(result.is_some());

let (_, change) = result.unwrap();
if let FileChange::Update { unified_diff, .. } = change {
// The diff should show line 3, not line 1
assert!(
unified_diff.contains("@@ -1,5 +1,5 @@")
|| unified_diff.contains("@@ -1,4 +1,4 @@")
|| unified_diff.contains("-line 3")
|| unified_diff.contains("line 2")
);

// Wait, if it uses the whole file, it should have the correct line numbers.
// For a 5 line file, it might still show @@ -1,5 +1,5 @@ if the whole file is context.
// Let's use a larger file to be sure.

let large_content = (1..=100).map(|i| format!("line {i}\n")).collect::<String>();
std::fs::write(&file_path, &large_content).unwrap();

let input2 = serde_json::json!({
"path": file_path.to_str().unwrap(),
"old_string": "line 50\n",
"new_string": "line 50 modified\n"
});

let result2 = tool_call_to_file_change(
Some(&acp::ToolKind::Edit),
Some(&input2),
temp_dir.path(),
);
let (_, change2) = result2.unwrap();
if let FileChange::Update { unified_diff, .. } = change2 {
// The diff should contain line 50
assert!(unified_diff.contains("-line 50"));
assert!(unified_diff.contains("+line 50 modified"));
// And the hunk header should NOT be @ -1,x +1,x
assert!(!unified_diff.contains("@@ -1,"));
// It should be adjusted to line 50
assert!(
unified_diff.contains("@@ -50 +50 @@")
|| unified_diff.contains("@@ -50,1 +50,1 @@")
);
}
} else {
panic!("Expected FileChange::Update");
}
}

#[test]
fn test_tool_call_to_file_change_write() {
let input = serde_json::json!({
"file_path": "/src/new_file.rs",
"content": "// New file\nfn main() {}\n"
});

let result = tool_call_to_file_change(Some(&acp::ToolKind::Edit), Some(&input));
let result = tool_call_to_file_change(
Some(&acp::ToolKind::Edit),
Some(&input),
std::path::Path::new("."),
);
assert!(result.is_some());

let (path, change) = result.unwrap();
Expand All @@ -1286,7 +1361,11 @@ mod tests {
"content": "// File to delete\n"
});

let result = tool_call_to_file_change(Some(&acp::ToolKind::Delete), Some(&input));
let result = tool_call_to_file_change(
Some(&acp::ToolKind::Delete),
Some(&input),
std::path::Path::new("."),
);
assert!(result.is_some());

let (path, change) = result.unwrap();
Expand All @@ -1306,7 +1385,11 @@ mod tests {
"content": "some content"
});

let result = tool_call_to_file_change(Some(&acp::ToolKind::Edit), Some(&input));
let result = tool_call_to_file_change(
Some(&acp::ToolKind::Edit),
Some(&input),
std::path::Path::new("."),
);
assert!(result.is_none());
}

Expand All @@ -1330,7 +1413,7 @@ mod tests {
vec![],
);

let event = permission_request_to_patch_approval_event(&request);
let event = permission_request_to_patch_approval_event(&request, std::path::Path::new("."));
assert!(event.is_some());

let event = event.unwrap();
Expand Down Expand Up @@ -1365,7 +1448,7 @@ mod tests {
vec![],
);

let event = permission_request_to_patch_approval_event(&request);
let event = permission_request_to_patch_approval_event(&request, std::path::Path::new("."));
assert!(event.is_none());
}

Expand Down Expand Up @@ -1503,7 +1586,7 @@ mod tests {
vec![],
);

let event = permission_request_to_patch_approval_event(&request);
let event = permission_request_to_patch_approval_event(&request, std::path::Path::new("."));
assert!(event.is_some());

let event = event.unwrap();
Expand Down
65 changes: 28 additions & 37 deletions nori-rs/acp/tests/tracing_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,18 @@ fn test_rolling_file_tracing_comprehensive() {
let result1 = nori_acp::init_rolling_file_tracing(&log_dir, "nori-acp");
assert!(result1.is_ok(), "First initialization should succeed");

let debug_enabled = tracing::enabled!(tracing::Level::DEBUG);
let info_enabled = tracing::enabled!(tracing::Level::INFO);
let warn_enabled = tracing::enabled!(tracing::Level::WARN);
let error_enabled = tracing::enabled!(tracing::Level::ERROR);
let trace_enabled = tracing::enabled!(tracing::Level::TRACE);

// Test 2: Emit test log events and verify they appear in file
debug!("This is a debug message");
info!("This is an info message");
warn!("This is a warning message");
error!("This is an error message");
tracing::trace!("This is a trace message that should not appear");
tracing::trace!("This is a trace message");

// Give async logger time to flush
std::thread::sleep(std::time::Duration::from_millis(100));
Expand All @@ -51,46 +57,31 @@ fn test_rolling_file_tracing_comprehensive() {
let log_file_path = log_files[0].path();
let contents = fs::read_to_string(&log_file_path).expect("Failed to read log file");

// Test 3: In debug builds, DEBUG and above should appear in the file
// In release builds, only WARN and above should appear
// Tests typically run in debug mode, so we expect debug messages
#[cfg(debug_assertions)]
{
assert!(
contents.contains("This is a debug message"),
"Log file should contain debug message in debug build"
);
assert!(
contents.contains("This is an info message"),
"Log file should contain info message in debug build"
);
}
#[cfg(not(debug_assertions))]
{
assert!(
!contents.contains("This is a debug message"),
"Log file should NOT contain debug message in release build"
);
assert!(
!contents.contains("This is an info message"),
"Log file should NOT contain info message in release build"
);
}

// WARN and ERROR should always be captured
assert!(
// Test 3: Captured messages should match the active subscriber filter.
assert_eq!(
contents.contains("This is a debug message"),
debug_enabled,
"debug message capture should match active filter"
);
assert_eq!(
contents.contains("This is an info message"),
info_enabled,
"info message capture should match active filter"
);
assert_eq!(
contents.contains("This is a warning message"),
"Log file should contain warning message"
warn_enabled,
"warning message capture should match active filter"
);
assert!(
assert_eq!(
contents.contains("This is an error message"),
"Log file should contain error message"
error_enabled,
"error message capture should match active filter"
);

// Test 4: Verify TRACE is always filtered out
assert!(
!contents.contains("This is a trace message"),
"Log file should NOT contain trace message (filtered out)"
assert_eq!(
contents.contains("This is a trace message"),
trace_enabled,
"trace message capture should match active filter"
);

// Test 5: Second initialization should fail (global subscriber already set)
Expand Down
1 change: 1 addition & 0 deletions nori-rs/core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ codex-utils-pty = { workspace = true }
codex-utils-readiness = { workspace = true }
codex-utils-string = { workspace = true }
codex-windows-sandbox = { package = "codex-windows-sandbox", path = "../windows-sandbox-rs" }
diffy = { workspace = true }
dirs = { workspace = true }
dunce = { workspace = true }
env-flags = { workspace = true }
Expand Down
16 changes: 9 additions & 7 deletions nori-rs/core/src/default_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,8 @@ mod tests {
#[test]
fn test_get_codex_user_agent() {
let user_agent = get_codex_user_agent();
assert!(user_agent.starts_with("nori_cli_rs/"));
let expected_prefix = format!("{}/", originator().value);
assert!(user_agent.starts_with(&expected_prefix));
}

#[tokio::test]
Expand Down Expand Up @@ -333,7 +334,7 @@ mod tests {
let originator_header = headers
.get("originator")
.expect("originator header missing");
assert_eq!(originator_header.to_str().unwrap(), "nori_cli_rs");
assert_eq!(originator_header.to_str().unwrap(), originator().value);

// User-Agent matches the computed Codex UA for that originator
let expected_ua = get_codex_user_agent();
Expand Down Expand Up @@ -370,10 +371,11 @@ mod tests {
fn test_macos() {
use regex_lite::Regex;
let user_agent = get_codex_user_agent();
let re = Regex::new(
r"^nori_cli_rs/\d+\.\d+\.\d+ \(Mac OS \d+\.\d+\.\d+; (x86_64|arm64)\) (\S+)$",
)
.unwrap();
assert!(re.is_match(&user_agent));
let expected_prefix = format!("{}/", originator().value);
assert!(user_agent.starts_with(&expected_prefix));
let version_and_platform = &user_agent[expected_prefix.len()..];
let re =
Regex::new(r"^\d+\.\d+\.\d+ \(Mac OS \d+\.\d+\.\d+; (x86_64|arm64)\) (\S+)$").unwrap();
assert!(re.is_match(version_and_platform));
}
}
8 changes: 5 additions & 3 deletions nori-rs/core/src/shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -357,9 +357,11 @@ mod tests {
let shell_path = bash_shell.shell_path;

assert!(
shell_path == PathBuf::from("/bin/bash")
|| shell_path == PathBuf::from("/usr/bin/bash")
|| shell_path == PathBuf::from("/usr/local/bin/bash"),
shell_path.is_absolute()
&& shell_path
.file_name()
.and_then(|file_name| file_name.to_str())
== Some("bash"),
"shell path: {shell_path:?}",
);
}
Expand Down
Loading