diff --git a/Cargo.toml b/Cargo.toml index 1a58684..41dc775 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agent-abstraction" -version = "0.4.5" +version = "0.4.6" edition = "2024" # The floor edition 2024 requires, and where the strictest dependencies (uuid, # getrandom) sit. Derived from the dependency graph rather than compile-tested. diff --git a/src/codex_app_server.rs b/src/codex_app_server.rs index acacadf..767d3e4 100644 --- a/src/codex_app_server.rs +++ b/src/codex_app_server.rs @@ -117,7 +117,12 @@ impl Protocol { fn start_turn(&self, thread_id: &str) -> String { let plan = self.request.plan(); let roots = roots(&self.request); - let writable: Vec = roots.iter().skip(1).cloned().collect(); + // app-server does not implicitly add `cwd` to a turn's writable roots. + // Verified against codex-cli 0.145.0 on 2026-08-02: omitting the first + // root leaves a one-directory request able to read its cwd but unable + // to write there. Every declared root therefore belongs in both the + // runtime scope and the workspace-write sandbox policy. + let writable = roots.clone(); let sandbox = match plan.permission { Permission::ReadOnly | Permission::Plan => { json!({"type": "readOnly", "networkAccess": false}) @@ -230,6 +235,7 @@ impl Protocol { { self.terminal.text = text.to_string(); } + step.events.push(Event::MessageBoundary); } else if let Some(event) = tool_result(item) { step.events.push(event); } @@ -536,7 +542,7 @@ mod tests { assert_eq!(turn["params"]["sandboxPolicy"]["type"], "workspaceWrite"); assert_eq!( turn["params"]["sandboxPolicy"]["writableRoots"], - json!(["/repo"]) + json!(["/workspace", "/repo"]) ); } @@ -572,6 +578,21 @@ mod tests { assert_eq!(usage.context_window, Some(258_400)); } + #[test] + fn completed_agent_messages_preserve_their_boundary() { + let mut protocol = Protocol::new(request()); + let step = protocol.push(&json!({ + "method": "item/completed", + "params": {"item": { + "type": "agentMessage", + "phase": "commentary", + "text": "I checked it." + }} + })); + + assert_eq!(step.events, vec![Event::MessageBoundary]); + } + #[test] fn permissions_can_be_granted_for_the_session() { let mut protocol = Protocol::new(request()); diff --git a/src/event.rs b/src/event.rs index 549dda5..84fd021 100644 --- a/src/event.rs +++ b/src/event.rs @@ -43,6 +43,13 @@ pub enum Event { Thinking(String), /// Assistant text as it arrives. Text(String), + /// One assistant message ended inside a turn that may produce another. + /// + /// This carries no text. It lets a streaming host preserve authored + /// message boundaries without inventing whitespace between token deltas. + /// Codex app-server exposes this boundary explicitly; the other transports + /// do not currently report an equivalent event. + MessageBoundary, /// The agent invoked a tool. ToolCall { /// Correlates with the matching [`Event::ToolResult`], when the agent @@ -242,6 +249,7 @@ fn bound_value(value: Value) -> Value { fn enforce_bounds(event: Event) -> Event { match event { Event::Text(text) => Event::Text(bound_text(text)), + Event::MessageBoundary => Event::MessageBoundary, Event::Thinking(text) => Event::Thinking(bound_text(text)), // An unusable id is dropped rather than shortened, so the event still // reports what the agent did while making the loss of correlation diff --git a/src/run.rs b/src/run.rs index 918db30..305fc2c 100644 --- a/src/run.rs +++ b/src/run.rs @@ -1043,6 +1043,20 @@ async fn drive_codex_app_server( while !protocol.finished { tokio::select! { biased; + // User steering and approval answers outrank the agent's output. + // app-server can keep stdout continuously ready with reasoning and + // text deltas; reading it first in a biased select could starve a + // correction precisely while Codex was busiest. + control = controls.recv() => { + let Some(control) = control else { + continue; + }; + pending.push_back(control); + flush_codex_controls(&mut protocol, &mut pending, &mut stdin, &bin).await?; + stdin.flush().await.map_err(|source| Error::Spawn { + bin: bin.clone(), source + })?; + } record = read_bounded_line(&mut reader, &mut line) => { if record.map_err(|source| Error::Spawn { bin: bin.clone(), source })?.is_some() { append_capped(&mut raw, &line); @@ -1080,16 +1094,6 @@ async fn drive_codex_app_server( protocol.finished = true; } } - control = controls.recv() => { - let Some(control) = control else { - continue; - }; - pending.push_back(control); - flush_codex_controls(&mut protocol, &mut pending, &mut stdin, &bin).await?; - stdin.flush().await.map_err(|source| Error::Spawn { - bin: bin.clone(), source - })?; - } () = &mut deadline => { let partial = protocol.terminal.text.clone(); shut_down(&mut child, stderr_task).await; diff --git a/tests/live.rs b/tests/live.rs index 16f7a3d..a3ccb80 100644 --- a/tests/live.rs +++ b/tests/live.rs @@ -574,6 +574,43 @@ async fn codex_app_server_accepts_a_live_steer() { let _ = std::fs::remove_dir_all(&dir); } +/// A single declared cwd is the common project shape in `AgencyZero`. It must be +/// writable without adding the same directory a second time as an extra root. +#[tokio::test] +#[ignore = "spawns a real agent and consumes quota"] +async fn codex_app_server_can_write_inside_its_cwd() { + if !available(Agent::Codex) { + return; + } + let dir = std::env::temp_dir().join(format!( + "agent-abstraction-codex-writable-cwd-{}", + std::process::id() + )); + std::fs::create_dir_all(&dir).expect("temp dir"); + let target = dir.join("written-inside-cwd.txt"); + let _ = std::fs::remove_file(&target); + + let request = Request::new( + Agent::Codex, + "Create the file written-inside-cwd.txt in the current working directory. \ + Its exact contents must be: writable", + ) + .cwd(&dir) + .permission(Permission::Auto) + .interactive() + .timeout(Duration::from_secs(180)); + + let outcome = run(&request) + .await + .expect("Codex should write inside its declared cwd"); + assert!(outcome.is_ok(), "the turn did not complete: {outcome:?}"); + assert_eq!( + std::fs::read_to_string(&target).expect("Codex did not create the file"), + "writable" + ); + let _ = std::fs::remove_dir_all(&dir); +} + /// A Codex sandbox escape is a server request the host can deny mid-turn. #[tokio::test] #[ignore = "spawns a real agent and consumes quota"]