Skip to content

Commit b765e54

Browse files
authored
feat(acp): Add ACP wire proxy logging (#460)
## Summary 🤖 Generated with [Nori](https://noriagentic.com/) - Adds config-gated ACP wire proxy logging via `[acp_proxy] enabled = true`, writing append-only JSONL files under `$NORI_HOME/acp-wire`. - Wraps each ACP child subprocess transport with raw JSON-RPC line logging in both directions, including prompt-summary subprocesses. - Documents the new diagnostic path and adds durable config plus real subprocess coverage. ## Test Plan - [x] `cargo test -p nori-acp` - [x] `cargo test -p nori-tui` - [x] `cargo build --bin nori && cargo test -p tui-pty-e2e` - [x] Manual TUI smoke with `elizacp`, temp `NORI_HOME`, `[acp_proxy] enabled = true`, and generated wire logs - [x] `just fmt` - [x] `just fix -p nori-acp` - [x] `just fix -p nori-tui` - [x] `git diff --check` Share Nori with your team: https://www.npmjs.com/package/nori-skillsets
1 parent e01aa5e commit b765e54

19 files changed

Lines changed: 464 additions & 44 deletions

File tree

nori-rs/acp/docs.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,19 @@ The config module provides the **canonical source of truth** for Nori home path
129129
- `CONFIG_FILE`: Config filename (`"config.toml"`)
130130
- `DEFAULT_AGENT`: Default agent (`"claude-code"`)
131131

132+
**ACP Wire Proxy Configuration** (`config/types/mod.rs`, `connection/`):
133+
134+
Nori can optionally wrap ACP subprocess transports with an append-only wire logger. The setting is top-level in `config.toml`:
135+
136+
```toml
137+
[acp_proxy]
138+
enabled = true
139+
```
140+
141+
When enabled, the resolved `AcpProxyConfig` stores logs under `$NORI_HOME/acp-wire`. The config layer intentionally owns this path resolution so every ACP entry point uses the same home directory semantics. The TUI passes the resolved proxy config into `AcpBackendConfig`; the backend passes it to each `SacpConnection::spawn()` call, including prompt-summary subprocesses, so every ACP child process gets its own log file.
142+
143+
The connection layer uses `sacp::Lines` to observe raw newline-delimited JSON-RPC messages at the transport boundary before or after SACP parsing. Each child process gets a distinct JSONL file named from the launch timestamp, child PID, and sanitized agent slug. Records include the timestamp, direction (`client_to_agent` or `agent_to_client`), agent slug, child PID, and the parsed JSON message. If a line cannot be parsed as JSON, the logger preserves the raw line and parse error instead of disrupting the live session.
144+
132145
**Agent Config Field Resolution:**
133146

134147
| Field | Purpose | Persistence |

nori-rs/acp/src/backend/hooks.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,13 @@ pub(super) async fn run_prompt_summary(
1010
user_prompt: &str,
1111
auto_worktree: crate::config::AutoWorktree,
1212
auto_worktree_repo_root: Option<&std::path::Path>,
13+
acp_proxy: crate::config::AcpProxyConfig,
1314
) -> Result<()> {
1415
use tokio::time::Duration;
1516
use tokio::time::timeout;
1617

1718
let agent_config = get_agent_config(agent_name)?;
18-
let mut connection = SacpConnection::spawn(&agent_config, cwd).await?;
19+
let mut connection = SacpConnection::spawn(&agent_config, cwd, acp_proxy).await?;
1920
let session_id = connection.create_session(cwd, vec![]).await?;
2021

2122
let summarization_prompt = format!(

nori-rs/acp/src/backend/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,8 @@ pub struct AcpBackendConfig {
185185
pub nori_home: PathBuf,
186186
/// History persistence policy
187187
pub history_persistence: crate::config::HistoryPersistence,
188+
/// ACP wire proxy logging settings
189+
pub acp_proxy: crate::config::AcpProxyConfig,
188190
/// CLI version for transcript metadata
189191
pub cli_version: String,
190192
/// Auto-worktree mode (whether a worktree was created at startup)
@@ -276,6 +278,8 @@ pub struct AcpBackend {
276278
nori_home: PathBuf,
277279
/// History persistence policy
278280
history_persistence: crate::config::HistoryPersistence,
281+
/// ACP wire proxy logging settings
282+
acp_proxy: crate::config::AcpProxyConfig,
279283
/// Conversation ID for this session (used for history entries)
280284
conversation_id: ConversationId,
281285
/// Sender for broadcasting approval policy updates to the handler

nori-rs/acp/src/backend/session.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ impl AcpBackend {
2525
acp_session_id, config.agent
2626
);
2727

28-
let mut connection = SacpConnection::spawn(&agent_config, &cwd)
28+
let mut connection = SacpConnection::spawn(&agent_config, &cwd, config.acp_proxy.clone())
2929
.await
3030
.map_err(|e| {
3131
let error_string = format!("{e:?}");
@@ -296,6 +296,7 @@ impl AcpBackend {
296296
idle_timer_abort: Arc::clone(&idle_timer_abort),
297297
nori_home: config.nori_home.clone(),
298298
history_persistence: config.history_persistence,
299+
acp_proxy: config.acp_proxy.clone(),
299300
conversation_id,
300301
approval_policy_tx,
301302
pending_compact_summary: Arc::new(Mutex::new(pending_summary)),

nori-rs/acp/src/backend/spawn_and_relay.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ impl AcpBackend {
2929
debug!("Spawning ACP backend for agent: {}", config.agent);
3030

3131
// Spawn the ACP connection with enhanced error handling
32-
let connection_result = SacpConnection::spawn(&agent_config, &cwd).await;
32+
let connection_result =
33+
SacpConnection::spawn(&agent_config, &cwd, config.acp_proxy.clone()).await;
3334

3435
let mut connection = match connection_result {
3536
Ok(conn) => conn,
@@ -158,6 +159,7 @@ impl AcpBackend {
158159
idle_timer_abort: Arc::clone(&idle_timer_abort),
159160
nori_home: config.nori_home.clone(),
160161
history_persistence: config.history_persistence,
162+
acp_proxy: config.acp_proxy.clone(),
161163
conversation_id,
162164
approval_policy_tx,
163165
pending_compact_summary: Arc::new(Mutex::new(config.initial_context.clone())),

nori-rs/acp/src/backend/tests/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,7 @@ fn build_test_config(temp_dir: &std::path::Path) -> AcpBackendConfig {
242242
os_notifications: crate::config::OsNotifications::Disabled,
243243
nori_home: temp_dir.to_path_buf(),
244244
history_persistence: crate::config::HistoryPersistence::SaveAll,
245+
acp_proxy: crate::config::AcpProxyConfig::disabled(),
245246
cli_version: "test".to_string(),
246247
notify_after_idle: crate::config::NotifyAfterIdle::FiveSeconds,
247248
auto_worktree: crate::config::AutoWorktree::Off,

nori-rs/acp/src/backend/tests/part3.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,7 @@ async fn test_mock_agent_auth_failure_produces_actionable_error() {
375375
os_notifications: crate::config::OsNotifications::Disabled,
376376
nori_home: temp_dir.path().to_path_buf(),
377377
history_persistence: crate::config::HistoryPersistence::SaveAll,
378+
acp_proxy: crate::config::AcpProxyConfig::disabled(),
378379
cli_version: "test".to_string(),
379380
notify_after_idle: crate::config::NotifyAfterIdle::FiveSeconds,
380381
auto_worktree: crate::config::AutoWorktree::Off,
@@ -1599,6 +1600,7 @@ async fn test_compact_sends_summarization_prompt_and_emits_events() {
15991600
os_notifications: crate::config::OsNotifications::Disabled,
16001601
nori_home: temp_dir.path().to_path_buf(),
16011602
history_persistence: crate::config::HistoryPersistence::SaveAll,
1603+
acp_proxy: crate::config::AcpProxyConfig::disabled(),
16021604
cli_version: "test".to_string(),
16031605
notify_after_idle: crate::config::NotifyAfterIdle::FiveSeconds,
16041606
auto_worktree: crate::config::AutoWorktree::Off,

nori-rs/acp/src/backend/tests/part4.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -596,6 +596,7 @@ async fn test_compact_prepends_summary_to_next_prompt() {
596596
os_notifications: crate::config::OsNotifications::Disabled,
597597
nori_home: temp_dir.path().to_path_buf(),
598598
history_persistence: crate::config::HistoryPersistence::SaveAll,
599+
acp_proxy: crate::config::AcpProxyConfig::disabled(),
599600
cli_version: "test".to_string(),
600601
notify_after_idle: crate::config::NotifyAfterIdle::FiveSeconds,
601602
auto_worktree: crate::config::AutoWorktree::Off,
@@ -758,6 +759,7 @@ async fn test_compact_not_in_unsupported_ops() {
758759
os_notifications: crate::config::OsNotifications::Disabled,
759760
nori_home: temp_dir.path().to_path_buf(),
760761
history_persistence: crate::config::HistoryPersistence::SaveAll,
762+
acp_proxy: crate::config::AcpProxyConfig::disabled(),
761763
cli_version: "test".to_string(),
762764
notify_after_idle: crate::config::NotifyAfterIdle::FiveSeconds,
763765
auto_worktree: crate::config::AutoWorktree::Off,

nori-rs/acp/src/backend/user_input.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ impl AcpBackend {
8888
let prompt_for_summary = display_text.clone();
8989
let auto_worktree = self.auto_worktree;
9090
let auto_worktree_repo_root = self.auto_worktree_repo_root.clone();
91+
let acp_proxy = self.acp_proxy.clone();
9192
tokio::spawn(async move {
9293
if let Err(e) = run_prompt_summary(
9394
&event_tx,
@@ -96,6 +97,7 @@ impl AcpBackend {
9697
&prompt_for_summary,
9798
auto_worktree,
9899
auto_worktree_repo_root.as_deref(),
100+
acp_proxy,
99101
)
100102
.await
101103
{

nori-rs/acp/src/config/loader.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ impl NoriConfig {
123123

124124
let skillset_per_session = toml.tui.skillset_per_session.unwrap_or(false);
125125
let auto_worktree = toml.tui.auto_worktree.unwrap_or_default();
126+
let acp_proxy = super::types::AcpProxyConfig::from_toml(toml.acp_proxy, &nori_home);
126127

127128
// Active agent is the runtime value: CLI override > config model > persisted agent > DEFAULT_AGENT
128129
// Using agent as fallback ensures the persisted preference is honored at startup
@@ -145,6 +146,7 @@ impl NoriConfig {
145146
history_persistence: toml
146147
.history_persistence
147148
.unwrap_or(super::types::HistoryPersistence::SaveAll),
149+
acp_proxy,
148150
animations: toml.tui.animations.unwrap_or(true),
149151
terminal_notifications: toml
150152
.tui
@@ -361,6 +363,37 @@ notify_after_idle = "30s"
361363
);
362364
}
363365

366+
#[test]
367+
fn test_acp_proxy_defaults_disabled() {
368+
let temp_dir = TempDir::new().unwrap();
369+
let config_path = temp_dir.path().join(CONFIG_FILE);
370+
371+
std::fs::write(&config_path, "").unwrap();
372+
373+
let config = NoriConfig::load_from_path(&config_path).unwrap();
374+
assert!(!config.acp_proxy.enabled);
375+
assert_eq!(config.acp_proxy.log_dir, temp_dir.path().join("acp-wire"));
376+
}
377+
378+
#[test]
379+
fn test_acp_proxy_enabled_from_config() {
380+
let temp_dir = TempDir::new().unwrap();
381+
let config_path = temp_dir.path().join(CONFIG_FILE);
382+
383+
std::fs::write(
384+
&config_path,
385+
r#"
386+
[acp_proxy]
387+
enabled = true
388+
"#,
389+
)
390+
.unwrap();
391+
392+
let config = NoriConfig::load_from_path(&config_path).unwrap();
393+
assert!(config.acp_proxy.enabled);
394+
assert_eq!(config.acp_proxy.log_dir, temp_dir.path().join("acp-wire"));
395+
}
396+
364397
#[test]
365398
fn test_model_uses_persisted_agent_as_fallback() {
366399
let temp_dir = TempDir::new().unwrap();

0 commit comments

Comments
 (0)