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
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,30 @@ release (see [`docs/adr/`](docs/adr/) for the full architectural record).

## [Unreleased]

### Added

- `[[agents]]` entries for `codex-acp` can set `rename_new_sessions = true` (default `false`) to stop
the adapter's title-generation thread. After a new session's first turn the adapter starts a second
codex thread, spends one model turn on it to generate a title, and never unsubscribes it; the bridge
has no id for that thread, so neither `session/close` on release nor anything short of adapter exit
ends the MCP server processes it started. With the option on, the bridge sends the adapter's own
slash command, `/rename <name>` as a `session/prompt`, once to each agent session it mints, after
mode, model and effort have settled and before the session can serve a turn. The adapter answers it
locally without a model turn and then skips title generation. The name is derived from the bridge
session key and is what the thread is listed under in codex's own session list. The rename is
best-effort: one attempt, bounded at 5 s, never retried, and it never fails the mint. Whatever
comes of it, the same session is published as it would be without the step, and a refused,
unanswered or cut-off rename is recorded only as a typed trace event. Updates the adapter sends
for the session before the rename exchange has ended reach no turn; output it sends late for a
rename it left unanswered past the bound can reach the session's first turn. Validation rejects
the option for any other `cmd`, where the text would reach the model as a prompt; a hot reload
that changes it respawns the agent's adapter; and model-catalog probes never rename. The option is
part of the frozen provider-effect identity, so a workflow run frozen under one value refuses an
entry reloaded with the other as a config mismatch; entries that leave it off keep the identity
they already had. The option relies on the adapter's `/rename` command as measured on codex-acp
1.13.1 and 2.2.2: see [`docs/compatibility.md`](docs/compatibility.md) before enabling it on
another version.

### Fixed

- `serve` and `mcp` now retire an agent's shared adapter process once no registry lease has used it
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ addr = "127.0.0.1:8080"
| `auth_method` | no | Auth method id for `authenticate` (defaults to ChatGPT-style auth when advertised, else the first advertised method); `"none"` skips client-driven `authenticate` entirely for agents already authenticated out-of-band |
| `pre_authenticated` | no | Skip `authenticate` because credentials are already ambient (for example, `codex login` or a mounted `auth.json`); cannot be combined with `auth_method` |
| `host_fallback_eligible` | no | Default `false`. Marks only an unsandboxed `kind="acp"` entry as an allowed target for a locally confirmed trusted-own-repo read-only fallback plan; it never asserts content trust or starts a fallback. |
| `rename_new_sessions` | no | Default `false`; rejected unless `cmd` is `codex-acp` (host or container). The bridge sends the adapter's `/rename <name>` slash command once to each agent session it mints, so the adapter skips the title-generation thread it otherwise starts after the session's first turn, along with that thread's extra model turn and MCP server processes. Best-effort (one attempt, bounded at 5 s; the same session is served whatever comes of it) and tied to the tested adapter versions: see [docs/compatibility.md](docs/compatibility.md#codex-acp-title-generation-thread-and-rename_new_sessions--2026-10-13). |
| `description`, `tags`, `version` | no | Seamed for future per-entry Agent Cards |

Model/effort resolution details, the effort-level-per-model table, and the `kind="api"` fields
Expand Down
56 changes: 56 additions & 0 deletions bin/a2a-bridge/src/catalog_probe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,9 @@ async fn probe_acp_host_with(
mode: None,
auth_method: entry.auth_method.clone(),
pre_authenticated: entry.pre_authenticated,
// Never the entry's value: a probe session runs no turn, so there is no title thread to
// head off, and a rename would only leave a named, empty entry in codex's session index.
rename_new_sessions: false,
container: None,
mcp: Vec::new(),
..AcpConfig::default()
Expand Down Expand Up @@ -551,6 +554,7 @@ mod tests {
auth_method: None,
pre_authenticated: false,
host_fallback_eligible: false,
rename_new_sessions: false,
name: None,
description: None,
tags: vec![],
Expand Down Expand Up @@ -667,6 +671,58 @@ mod tests {
);
}

#[tokio::test]
async fn acp_probe_never_renames_the_sessions_it_mints() {
struct ConfigCapturingSpawner {
configs: std::sync::Mutex<Vec<AcpConfig>>,
backend: Arc<RecordingProbeBackend>,
}

#[async_trait::async_trait]
impl CatalogAcpSpawner for ConfigCapturingSpawner {
async fn spawn_observed(
&self,
_cmd: &str,
_args: &[&str],
config: AcpConfig,
_observer: Arc<dyn DiagnosticObserver>,
) -> Result<Arc<dyn CatalogAcpBackend>, bridge_core::error::BridgeError> {
self.configs.lock().unwrap().push(config);
Ok(self.backend.clone())
}
}

let spawner = ConfigCapturingSpawner {
configs: std::sync::Mutex::new(Vec::new()),
backend: Arc::new(RecordingProbeBackend {
described: std::sync::Mutex::new(Vec::new()),
retired: std::sync::atomic::AtomicBool::new(false),
}),
};
// Host and container kinds alike are probed host-side, from an entry that opted in.
for kind in [AgentKind::Acp, AgentKind::ContainerRw] {
let mut renaming = entry("codex", Some("codex-acp"), kind);
renaming.rename_new_sessions = true;
renaming.pre_authenticated = true;
probe_acp_host_with(&renaming, Path::new("/tmp"), &spawner)
.await
.unwrap();
}

let configs = spawner.configs.lock().unwrap();
assert_eq!(configs.len(), 2);
for config in configs.iter() {
assert!(
config.pre_authenticated,
"control: the probe config does carry the entry's other settings"
);
assert!(
!config.rename_new_sessions,
"a probe session never runs a turn, so it must not be renamed"
);
}
}

#[test]
fn collect_probe_report_keeps_failures_outside_server_catalog() {
let bad_entry = entry("bad", Some("bad-acp"), AgentKind::Acp);
Expand Down
215 changes: 211 additions & 4 deletions bin/a2a-bridge/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1058,6 +1058,11 @@ pub struct AgentEntryToml {
/// authorize execution; absent/default false keeps existing entries ineligible.
#[serde(default)]
pub host_fallback_eligible: bool,
/// Send `/rename <name>` to each agent session this entry mints, so `codex-acp` finds an
/// explicit name after the first turn and skips its title-generation thread. Absent/default
/// false; only valid when `cmd` is `codex-acp`.
#[serde(default)]
pub rename_new_sessions: bool,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
Expand Down Expand Up @@ -1243,6 +1248,19 @@ fn build_mcp_specs(
Ok(out)
}

/// The file name of an agent's `cmd`, which is how the adapter behind an entry is told apart
/// whether `cmd` is a bare program name or a full path.
fn cmd_file_name(cmd: Option<&str>) -> Option<&str> {
cmd.and_then(|c| std::path::Path::new(c).file_name())
.and_then(|s| s.to_str())
}

/// Whether `cmd` launches the codex adapter under its own file name. A wrapper script with
/// another name is not recognized, on a host and in a container alike.
fn cmd_is_codex_acp(cmd: Option<&str>) -> bool {
cmd_file_name(cmd) == Some("codex-acp")
}

/// Resolve the MCP delivery channel: explicit `mcp_delivery` override wins; else auto-detect from the
/// `cmd` basename. Only required when the agent actually has MCP servers (`has_mcp`).
fn resolve_mcp_delivery(
Expand All @@ -1262,10 +1280,7 @@ fn resolve_mcp_delivery(
))),
};
}
let base = cmd
.and_then(|c| std::path::Path::new(c).file_name())
.and_then(|s| s.to_str());
match base {
match cmd_file_name(cmd) {
Some("claude-agent-acp") => Ok(McpDelivery::Acp),
Some("codex-acp") => Ok(McpDelivery::CodexNative),
Some("kiro-cli") => Ok(McpDelivery::KiroNative),
Expand Down Expand Up @@ -2151,6 +2166,14 @@ impl RegistryConfig {
id.as_str()
)));
}
// `/rename` is a slash command of the codex adapter alone. Any other adapter would
// hand the text to its model as the session's first prompt.
if a.rename_new_sessions && !cmd_is_codex_acp(a.cmd.as_deref()) {
return Err(ConfigError::Registry(format!(
"agent {:?}: rename_new_sessions=true requires cmd \"codex-acp\"",
id.as_str()
)));
}
let watchdog = match &a.watchdog {
None => None,
Some(wd) => {
Expand Down Expand Up @@ -2206,6 +2229,7 @@ impl RegistryConfig {
auth_method: a.auth_method,
pre_authenticated: a.pre_authenticated,
host_fallback_eligible: a.host_fallback_eligible,
rename_new_sessions: a.rename_new_sessions,
name: a.name,
description: a.description,
tags: a.tags,
Expand Down Expand Up @@ -3053,6 +3077,189 @@ egress = "open"
}
}

#[test]
fn rename_new_sessions_defaults_false_and_accepts_codex_acp_on_host_and_in_containers() {
let default_snapshot = RegistryConfig::parse(
r#"
default = "codex"

[[agents]]
id = "codex"
cmd = "codex-acp"

[server]
"#,
)
.unwrap()
.into_snapshot()
.unwrap();
assert!(!default_snapshot.entries[0].rename_new_sessions);

let accepted = [
(
"host, bare cmd",
r#"
default = "codex"

[[agents]]
id = "codex"
cmd = "codex-acp"
rename_new_sessions = true

[server]
"#,
),
(
"host, cmd given as a path",
r#"
default = "codex"

[[agents]]
id = "codex"
cmd = "/usr/local/bin/codex-acp"
rename_new_sessions = true

[server]
"#,
),
(
"read-only container",
r#"
default = "codex"
allowed_cwd_root = "/repo"

[[agents]]
id = "codex"
cmd = "codex-acp"
rename_new_sessions = true

[agents.sandbox]
image = "reader:latest"
mount = "/repo"
access = "ro"
egress = "open"

[server]
"#,
),
(
"container_rw",
r#"
default = "codex"
allowed_cwd_root = "/repo"

[[agents]]
id = "codex"
kind = "container_rw"
cmd = "codex-acp"
rename_new_sessions = true

[agents.sandbox]
image = "writer:latest"
mount = "/repo"
access = "rw"
egress = "open"

[server]
"#,
),
];
for (case, toml) in accepted {
let snapshot = RegistryConfig::parse(toml)
.unwrap()
.into_snapshot()
.unwrap_or_else(|error| panic!("{case} must be accepted: {error}"));
assert!(snapshot.entries[0].rename_new_sessions, "{case}");
}
}

#[test]
fn rename_new_sessions_rejects_every_cmd_but_codex_acp() {
let invalid = [
(
"another adapter",
r#"
default = "target"

[[agents]]
id = "target"
cmd = "claude-agent-acp"
rename_new_sessions = true

[server]
"#,
),
(
"a wrapper under another file name",
r#"
default = "target"

[[agents]]
id = "target"
cmd = "/opt/wrappers/codex-acp-wrapper"
rename_new_sessions = true

[server]
"#,
),
(
"another adapter in a container",
r#"
default = "target"
allowed_cwd_root = "/repo"

[[agents]]
id = "target"
kind = "container_rw"
cmd = "claude-agent-acp"
rename_new_sessions = true

[agents.sandbox]
image = "writer:latest"
mount = "/repo"
access = "rw"
egress = "open"

[server]
"#,
),
(
"an api agent, which has no cmd",
r#"
default = "target"

[[agents]]
id = "target"
kind = "api"
base_url = "http://localhost:11434/v1"
rename_new_sessions = true

[server]
"#,
),
];

for (case, toml) in invalid {
let error = RegistryConfig::parse(toml)
.unwrap()
.into_snapshot()
.unwrap_err()
.to_string();
assert!(
error.contains("\"target\"") && error.contains("rename_new_sessions"),
"{case} rejection should name the agent and the field: {error}"
);
}

// The same entries are valid once the option is left off.
for (case, toml) in invalid {
RegistryConfig::parse(&toml.replace("rename_new_sessions = true\n", ""))
.unwrap()
.into_snapshot()
.unwrap_or_else(|error| panic!("{case} without the option: {error}"));
}
}

// -----------------------------------------------------------------------
// FileConfigSource tests (Task 8 / Increment 3b)
// -----------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions bin/a2a-bridge/src/doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2534,6 +2534,7 @@ mod tests {
auth_method: None,
pre_authenticated: false,
host_fallback_eligible: false,
rename_new_sessions: false,
name: None,
description: None,
tags: vec![],
Expand Down
Loading
Loading