Skip to content

Commit 6c58b10

Browse files
authored
feat(acp): Add approval bridging infrastructure (#62)
## Summary 🤖 Generated with [Nori](https://www.npmjs.com/package/nori-ai) - Implements approval bridging between ACP permission requests and Codex approval system - Adds `ApprovalRequest` type and translation functions for bi-directional conversion - Exposes approval channel via `AcpConnection::take_approval_receiver()` for TUI integration - Adds placeholder comments for future features: history persistence, export, resume/fork ## Test Plan - [x] All existing tests pass (12 unit tests + 1 integration test + 1 doc test) - [x] New translator tests verify approval translation logic - [x] Manual testing with Claude ACP agent when TUI integration is complete Share Nori with your team: https://www.npmjs.com/package/nori-ai
1 parent 1e44916 commit 6c58b10

6 files changed

Lines changed: 677 additions & 16 deletions

File tree

codex-rs/acp/DESIGN_DECISIONS.md

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
# ACP Integration: Critical Design Decisions
2+
3+
## Core Principle: Minimal Footprint
4+
5+
The ACP implementation is designed to **minimize changes to codex-core** to ease the burden of accepting upstream changes. This drives all other decisions.
6+
7+
---
8+
9+
## Architecture Decisions
10+
11+
### 1. Parallel Crate Structure (Not Trait Abstraction)
12+
13+
**Decision:** `codex-acp` is a parallel crate to `codex-core`, NOT integrated via a shared trait.
14+
15+
**Rejected Alternative:** An `AgentBackend` trait that would unify HTTP and ACP:
16+
```rust
17+
// REJECTED - pollutes core with ACP concerns
18+
trait AgentBackend {
19+
fn owns_tool_execution(&self) -> bool; // ACP-specific branching
20+
fn supports_session_resume(&self) -> bool; // ACP capability check
21+
fn permission_requests(&self) -> Receiver; // ACP protocol detail
22+
}
23+
```
24+
25+
**Why:** Every method except `execute_turn()` would exist to handle ACP's differences, causing:
26+
- Core logic branches on `owns_tool_execution()`
27+
- Testing must cover both paths
28+
- Upstream changes risk breaking ACP paths
29+
30+
**Result:** Zero changes to codex-core. ACP is self-contained.
31+
32+
---
33+
34+
### 2. Mode Selection: Config-Only at Startup
35+
36+
**Decision:** ACP vs HTTP mode is determined at startup via configuration. No mid-session switching.
37+
38+
**Implications:**
39+
- Model picker stays HTTP-only
40+
- No changes to `Op::OverrideTurnContext`
41+
- TUI branches once at startup, not per-turn
42+
43+
**How core calling code changes:**
44+
```rust
45+
// In TUI/CLI main():
46+
if config.acp_agent.is_some() {
47+
// ACP mode: use codex-acp directly
48+
let connection = AcpConnection::spawn(config).await;
49+
run_acp_event_loop(connection);
50+
} else {
51+
// HTTP mode: use codex-core (unchanged)
52+
let codex = Codex::spawn(config);
53+
run_event_loop(codex.events());
54+
}
55+
```
56+
57+
---
58+
59+
### 3. History Ownership: ACP Owns It
60+
61+
**Decision:** ACP agents fully manage their own history. Codex does not persist or convert ACP conversations.
62+
63+
**Implications:**
64+
- Zero history conversion code in core
65+
- If user switches backends, history doesn't transfer (by design)
66+
- ACP agents may have proprietary context handling
67+
68+
**Future Placeholders Added:**
69+
- `connection.rs:343-350` - Resume/fork integration point
70+
- `connection.rs:385-394` - Codex-format history persistence
71+
- `connection.rs:220-234` - History export for backend handoff
72+
73+
---
74+
75+
### 4. Approval Bridging: Codex Intercedes via Event Translation
76+
77+
**Decision:** ACP permission requests flow through Codex's approval UI via channel-based event translation.
78+
79+
**Flow:**
80+
```
81+
ACP Worker Thread Main Thread (TUI)
82+
│ │
83+
│ ApprovalRequest │
84+
│ ──────────────────────────────────►│
85+
│ (ExecApprovalRequestEvent + │ Display approval UI
86+
│ options + oneshot::Sender) │ Get user decision
87+
│ │
88+
│ ReviewDecision │
89+
│ ◄──────────────────────────────────│
90+
│ (via oneshot channel) │
91+
│ │
92+
▼ ▼
93+
Translate to ACP outcome Continue processing
94+
```
95+
96+
**Key Types:**
97+
```rust
98+
pub struct ApprovalRequest {
99+
pub event: ExecApprovalRequestEvent, // Codex format
100+
pub options: Vec<acp::PermissionOption>, // For response translation
101+
pub response_tx: oneshot::Sender<ReviewDecision>,
102+
}
103+
```
104+
105+
**How TUI integrates:**
106+
```rust
107+
let mut connection = AcpConnection::spawn(config).await?;
108+
let approval_rx = connection.take_approval_receiver();
109+
110+
// In event loop:
111+
tokio::select! {
112+
Some(approval) = approval_rx.recv() => {
113+
// Show approval UI
114+
let decision = show_approval_popup(approval.event).await;
115+
// Send decision back
116+
let _ = approval.response_tx.send(decision);
117+
}
118+
// ... other events
119+
}
120+
```
121+
122+
---
123+
124+
### 5. Translation Layer: Bi-directional Type Conversion
125+
126+
**Decision:** `translator.rs` handles all ACP ↔ Codex type conversions.
127+
128+
**Functions:**
129+
| Function | Direction |
130+
|----------|-----------|
131+
| `permission_request_to_approval_event()` | ACP → Codex |
132+
| `review_decision_to_permission_outcome()` | Codex → ACP |
133+
| `response_items_to_content_blocks()` | Codex → ACP |
134+
| `translate_session_update()` | ACP → Codex |
135+
136+
**Approval Translation Logic:**
137+
- `Approved`/`ApprovedForSession` → Find `AllowOnce`/`AllowAlways` option
138+
- `Denied`/`Abort` → Find `RejectOnce`/`RejectAlways` option
139+
- Fallback: text matching ("allow", "approve", "yes" vs "deny", "reject", "no")
140+
- Last resort: first option for approve, last for deny
141+
142+
---
143+
144+
## How Core Module Calling Code Must Change
145+
146+
### Current State (HTTP-only)
147+
```rust
148+
// codex-tui/src/main.rs (simplified)
149+
let codex = Codex::spawn(config);
150+
let events = codex.events();
151+
run_event_loop(events);
152+
```
153+
154+
### Required Changes
155+
156+
1. **Add ACP config detection:**
157+
```rust
158+
// Add to config parsing
159+
struct Config {
160+
// existing fields...
161+
acp_agent: Option<AcpAgentConfig>, // NEW
162+
}
163+
```
164+
165+
2. **Branch at startup:**
166+
```rust
167+
async fn main() {
168+
let config = load_config();
169+
170+
if let Some(acp_config) = &config.acp_agent {
171+
run_acp_mode(acp_config, &config).await;
172+
} else {
173+
run_http_mode(&config).await; // Existing code path
174+
}
175+
}
176+
```
177+
178+
3. **ACP mode implementation:**
179+
```rust
180+
async fn run_acp_mode(acp_config: &AcpAgentConfig, config: &Config) {
181+
// Spawn ACP connection
182+
let mut connection = AcpConnection::spawn(acp_config, &config.cwd).await?;
183+
184+
// Take approval receiver for UI integration
185+
let approval_rx = connection.take_approval_receiver();
186+
187+
// Create session
188+
let session_id = connection.create_session(&config.cwd).await?;
189+
190+
// Event loop with approval handling
191+
loop {
192+
tokio::select! {
193+
Some(approval) = approval_rx.recv() => {
194+
handle_approval_request(approval);
195+
}
196+
user_input = get_user_input() => {
197+
let (update_tx, mut update_rx) = mpsc::channel(32);
198+
connection.prompt(&session_id, prompt, update_tx).await?;
199+
200+
while let Some(update) = update_rx.recv().await {
201+
let events = translator::translate_session_update(update);
202+
for event in events {
203+
display_event(event);
204+
}
205+
}
206+
}
207+
}
208+
}
209+
}
210+
```
211+
212+
4. **HTTP mode unchanged:**
213+
```rust
214+
async fn run_http_mode(config: &Config) {
215+
// Existing codex-core code path - UNCHANGED
216+
let codex = Codex::spawn(config);
217+
run_event_loop(codex.events());
218+
}
219+
```
220+
221+
---
222+
223+
## Files Modified/Created
224+
225+
| File | Change |
226+
|------|--------|
227+
| `codex-rs/acp/src/connection.rs` | Added `ApprovalRequest`, approval channel, placeholders |
228+
| `codex-rs/acp/src/translator.rs` | Added approval translation functions |
229+
| `codex-rs/acp/src/lib.rs` | Exported `ApprovalRequest` |
230+
| `codex-rs/acp/docs.md` | Updated documentation |
231+
232+
## Files NOT Modified
233+
234+
| File | Why |
235+
|------|-----|
236+
| `codex-rs/core/*` | **Zero changes** - minimal footprint principle |
237+
| `codex-rs/tui/*` | TUI integration is separate task |
238+
239+
---
240+
241+
## Summary
242+
243+
The ACP integration follows a **parallel architecture** where:
244+
- `codex-core` remains unchanged (upstream compatibility)
245+
- `codex-acp` is self-contained with its own session management
246+
- TUI/CLI chooses mode at startup based on config
247+
- Approval bridging is the single integration point, using channel-based event translation
248+
- History/resume features are stubbed with clear placeholder comments for future work

codex-rs/acp/DESIGN_SUMMARY.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# ACP Integration Design Summary
2+
3+
- `codex-acp` is a parallel crate to `codex-core`, not integrated via shared traits
4+
- Zero modifications to `codex-core` to ease upstream merge burden
5+
- ACP vs HTTP mode is determined at startup via config, no mid-session switching
6+
- TUI/CLI branches once at startup: `if config.acp_agent.is_some() { run_acp_mode() } else { run_http_mode() }`
7+
- HTTP mode code path remains completely unchanged
8+
- ACP agents fully own their conversation history; Codex does not persist or convert it
9+
- Placeholder comments mark future integration points for history persistence, export, and resume/fork
10+
- Approval bridging is the single integration point between ACP and Codex UI
11+
- `ApprovalRequest` bundles `ExecApprovalRequestEvent`, ACP options, and a oneshot response channel
12+
- TUI receives approval requests via `AcpConnection::take_approval_receiver()`
13+
- TUI sends user decision back via the oneshot channel as `ReviewDecision`
14+
- `translator.rs` handles bi-directional type conversion between ACP and Codex formats
15+
- `permission_request_to_approval_event()` converts ACP requests to Codex format
16+
- `review_decision_to_permission_outcome()` converts Codex decisions back to ACP format
17+
- Fallback behavior: auto-approve if approval channel closed, deny if response channel dropped
18+
- Model picker stays HTTP-only; ACP agents are not treated as switchable "models"

codex-rs/acp/docs.md

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,10 +92,29 @@ The ACP library uses `LocalBoxFuture` which is `!Send`, preventing direct use in
9292

9393
- Implements `acp::Client` trait to handle agent requests
9494
- Routes session updates to registered `mpsc::Sender<SessionUpdate>` channels
95-
- Auto-approves permission requests (TODO: bridge to codex approval system)
95+
- Bridges permission requests to Codex approval system via `ApprovalRequest` channel
9696
- Implements file read (synchronous `std::fs::read_to_string`)
9797
- Terminal operations return `method_not_found` (not yet supported)
9898

99+
**Approval Bridging:**
100+
101+
The ACP module bridges permission requests to Codex's approval UI:
102+
103+
```
104+
┌─────────────────────────┐ ApprovalRequest ┌─────────────────────────┐
105+
│ ACP Worker Thread │──────────────────────►│ Main Thread (TUI) │
106+
│ │ │ │
107+
│ ClientDelegate │ │ - Display approval UI │
108+
│ - request_permission()│◄──────────────────────│ - Get user decision │
109+
│ │ ReviewDecision │ - Send via oneshot │
110+
└─────────────────────────┘ (via oneshot) └─────────────────────────┘
111+
```
112+
113+
- `ApprovalRequest` bundles the translated `ExecApprovalRequestEvent`, original ACP options, and response channel
114+
- `AcpConnection::take_approval_receiver()` exposes the receiver for TUI consumption
115+
- Falls back to auto-approve if approval channel is closed (no UI listening)
116+
- Falls back to deny if response channel is dropped (UI didn't respond)
117+
99118
**Event Translation (`translator.rs`):**
100119

101120
Bridges between ACP types and codex-protocol types:
@@ -105,13 +124,24 @@ Bridges between ACP types and codex-protocol types:
105124
| `response_items_to_content_blocks()` | Converts codex `ResponseItem` to ACP `ContentBlock` for prompts |
106125
| `text_to_content_block()` | Simple text-to-ContentBlock conversion |
107126
| `translate_session_update()` | Translates ACP `SessionUpdate` to `TranslatedEvent` enum |
127+
| `permission_request_to_approval_event()` | Converts ACP `RequestPermissionRequest` to Codex `ExecApprovalRequestEvent` |
128+
| `review_decision_to_permission_outcome()` | Converts Codex `ReviewDecision` back to ACP `RequestPermissionOutcome` |
108129

109130
`TranslatedEvent` variants:
110131
- `TextDelta(String)` - Text content from `AgentMessageChunk` or `AgentThoughtChunk`
111132
- `Completed(StopReason)` - Session completion signal
112133

113134
Non-text content (images, audio, resources) and tool calls are currently dropped with empty vec.
114135

136+
**Approval Translation Details:**
137+
138+
The approval translation maps between Codex's binary approve/deny model and ACP's option-based model:
139+
140+
- `Approved`/`ApprovedForSession` → Finds option with `AllowOnce` or `AllowAlways` kind
141+
- `Denied`/`Abort` → Finds option with `RejectOnce` or `RejectAlways` kind
142+
- Falls back to text matching ("allow", "approve", "yes" vs "deny", "reject", "no") if kind-based matching fails
143+
- Last resort: first option for approve, last option for deny
144+
115145
### Things to Know
116146

117147
**Protocol Version Check:**
@@ -139,4 +169,23 @@ Client advertises these capabilities to agents:
139169
- `fs.write_text_file: true`
140170
- `terminal: false`
141171

172+
### Future Work
173+
174+
The following features are marked with TODO comments in the codebase:
175+
176+
**Resume/Fork Integration (connection.rs:343-350):**
177+
- Accept optional session_id parameter to resume existing sessions
178+
- Load persisted history from Codex rollout format
179+
- Send history to agent via session initialization
180+
181+
**Codex-format History Persistence (connection.rs:385-394):**
182+
- Collect all SessionUpdates during prompts
183+
- Convert to Codex ResponseItem format using translator functions
184+
- Write to rollout storage for session resume and history browsing
185+
186+
**History Export for Handoff (connection.rs:220-234):**
187+
- Export session history in Codex format
188+
- Enable switching from ACP mode to HTTP mode mid-session
189+
- Support replaying history through different backends
190+
142191
Created and maintained by Nori.

0 commit comments

Comments
 (0)