Skip to content
Merged
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
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
25 changes: 23 additions & 2 deletions src/codex_app_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = 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})
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -536,7 +542,7 @@ mod tests {
assert_eq!(turn["params"]["sandboxPolicy"]["type"], "workspaceWrite");
assert_eq!(
turn["params"]["sandboxPolicy"]["writableRoots"],
json!(["/repo"])
json!(["/workspace", "/repo"])
);
}

Expand Down Expand Up @@ -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());
Expand Down
8 changes: 8 additions & 0 deletions src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
24 changes: 14 additions & 10 deletions src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
37 changes: 37 additions & 0 deletions tests/live.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
Loading