Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
148 commits
Select commit Hold shift + click to select a range
66b487a
feat(agent): add connection testing and bare assistant projection
Jun 15, 2026
3c742a9
Merge remote-tracking branch 'origin/main' into feat/agent-connection…
Jun 15, 2026
9ced726
chore(assistant): remove unused preset id whitelist asset
Jun 16, 2026
645eff8
Merge remote-tracking branch 'origin/main' into feat/agent-connection…
Jun 16, 2026
5f10d94
feat(assistant): prioritize bare assistants on first bootstrap
Jun 16, 2026
8431999
merge: bring origin/main into feat/agent-connection-testing-phase2
Jun 16, 2026
6523326
test(assistant): cover bare assistant projection
Jun 16, 2026
6a040d9
feat(channel): add backend-owned channel settings API
Jun 16, 2026
ab35b57
chore: apply auto-fixes (fmt + clippy)
Jun 16, 2026
24fb0ce
test(api): refresh assistant response fixture
Jun 16, 2026
6226104
feat(channel): resolve bindings from assistants
Jun 16, 2026
c3e02ce
fix(agent): kill probe process group to stop wrapper grandchild leak
Jun 16, 2026
d821d4d
feat(team): persist assistant identity across team flows
Jun 16, 2026
c176bb8
refactor(cron): persist assistant identity in cron config
Jun 16, 2026
940e287
feat(agent): probe managed builtin acp health
Jun 16, 2026
f14083f
refactor(agent): drop legacy backend health check route
Jun 16, 2026
4bdab9b
refactor(cron): create conversations with assistant identity
Jun 16, 2026
7263515
refactor(channel): normalize assistant bindings on write
Jun 16, 2026
987f8f1
feat(agent): surface management diagnostics guidance
Jun 16, 2026
742ba2b
fix(assistant): forbid editing generated assistants
Jun 16, 2026
f2e7975
refactor(team): persist assistant identity for new agents
Jun 16, 2026
86033c2
refactor(conversation): inject assistant runtime seeds
Jun 16, 2026
907ace6
refactor(cron): strip legacy agent ids on assistant writes
Jun 16, 2026
d6c8161
refactor(team): prefer assistant ids in mcp tooling
Jun 16, 2026
afb071e
fix(team): resolve spawn backend from assistant ids
Jun 16, 2026
6709f85
refactor(team): derive team backends from assistants
Jun 16, 2026
712d5fc
refactor(conversation): drop redundant preset extra writes
Jun 16, 2026
dfd6d18
refactor(team): prefer assistant ids in leader prompts
Jun 16, 2026
f71aa96
refactor(channel): create conversations through assistant identities
Jun 16, 2026
f5ebc0a
refactor(team): seed lead prompts from assistants
Jun 16, 2026
16d53ed
refactor(team): reword mcp tools around assistants
Jun 16, 2026
fe74d85
refactor(team): reword prompts around assistants
Jun 16, 2026
ab711d4
refactor(channel): split assistant setting read and write contracts
Jun 17, 2026
c673afa
refactor(channel): prefer assistant-first channel settings
Jun 17, 2026
a885ef2
test(channel): expect assistant-only binding writes
Jun 17, 2026
5568f5d
refactor(team): make backend optional for assistant-led requests
Jun 17, 2026
93c7371
refactor(channel): drop direct agent switching
Jun 17, 2026
258b139
refactor(team): make prompts and tools assistant-first
Jun 17, 2026
940f199
refactor(team): remove preset assistant wording
Jun 17, 2026
b041484
refactor(team): stop writing preset assistant ids
Jun 17, 2026
af4500a
refactor(team): stop echoing legacy custom agent ids
Jun 17, 2026
5301e62
refactor(cron): canonicalize legacy assistant ids on write
Jun 17, 2026
46bf6f1
refactor(cron): stop promoting legacy assistant ids
Jun 17, 2026
fb0a657
refactor(cron): split cron write agent config dto
Jun 17, 2026
d3e96eb
refactor(conversation): expose explicit assistant identity
Jun 17, 2026
e65ba82
test(cron): stop asserting legacy preset assistant ids
Jun 17, 2026
da98c48
refactor(cron): derive runtime type from assistants
Jun 17, 2026
5b3d125
refactor(team): pass assistant identity to conversations
Jun 17, 2026
fe91dd8
refactor(team): accept assistant-first request payloads
Jun 17, 2026
2f5ee95
refactor(team): drop legacy mcp spawn aliases
Jun 17, 2026
513a408
refactor(team): canonicalize legacy custom agent ids
Jun 17, 2026
e011837
refactor(conversation): derive create type from assistants
Jun 17, 2026
4f761ac
refactor(channel): stop persisting assistant backends in extra
Jun 17, 2026
3fd2bbf
refactor(conversation): stop writing legacy assistant ids
Jun 17, 2026
d6ca58a
refactor(cron): omit legacy agent hints for assistant creates
Jun 17, 2026
03a7f5f
refactor(team): derive guide leaders from assistant conversations
Jun 17, 2026
0691319
refactor(team): require assistant ids for mcp spawning
Jun 17, 2026
d6dd73f
refactor(team): emit assistant-native response fields
Jun 17, 2026
6e10e04
refactor(channel): normalize assistant-first platform settings
Jun 17, 2026
bae5468
refactor(conversation): prefer persisted acp session identity
Jun 17, 2026
1e35339
refactor(channel): prefer assistant names for conversations
Jun 17, 2026
6b01b29
fix(conversation): persist resolved agent identity from snapshot
Jun 17, 2026
826a96e
refactor(team): prefer assistant ids in command shims
Jun 17, 2026
224b6fb
fix(conversation): prefer snapshot runtime identity
Jun 17, 2026
1efb084
refactor(cron): strip legacy ids from assistant responses
Jun 17, 2026
764a64b
refactor(cron): prefer runtime backend over stale extra
Jun 17, 2026
466a938
feat(agent): expose backend logo catalog endpoint
Jun 17, 2026
05aea56
fix(cron): prefer assistant backend when creating jobs
Jun 17, 2026
383036e
refactor(cron): canonicalize legacy assistant ids in responses
Jun 17, 2026
9a9985e
refactor(channel): clear sessions on settings updates
Jun 17, 2026
6de2169
refactor(team): query models by assistant identity
Jun 17, 2026
93d8ca5
refactor(cron): reject legacy write agent fields
Jun 17, 2026
879f439
refactor(cron): strip legacy backend from assistant responses
Jun 17, 2026
bf647f6
refactor(team): route stdio model lookup by assistant
Jun 17, 2026
7107f19
refactor(team): rename list models response to backends
Jun 17, 2026
95e2278
refactor(cron): derive backend from assistants
Jun 17, 2026
d1df342
refactor(team): ignore caller backend for assistants
Jun 17, 2026
9ce4833
refactor(channel): canonicalize legacy backend bindings
Jun 17, 2026
273968d
refactor(cron): require assistant ids for public create requests
Jun 17, 2026
6144f3f
refactor(team): reject legacy custom agent ids in write dtos
Jun 17, 2026
98b718a
refactor(team): require assistant ids for public writes
Jun 18, 2026
0e7fcc4
refactor(agent): drop legacy public agents catalog
Jun 18, 2026
ca65c80
refactor(team): prefer assistant avatars in responses
Jun 18, 2026
373d2fc
fix(agent): reap probe process tree to stop orphan leak
Jun 18, 2026
171ca04
fix(agent): key logo catalog by agent_type when backend is null
Jun 18, 2026
8c61784
refactor(team): reject legacy stdio command aliases
Jun 18, 2026
aeb842b
refactor(cron): require assistant backend resolution on update
Jun 18, 2026
0ed5e36
refactor(team): prefer assistant-first response fields
Jun 18, 2026
49ef39f
fix(team): append gemini to guide backends response
Jun 18, 2026
cc2d3f8
refactor(cron): prefer assistant ids in task extras
Jun 18, 2026
e72d955
refactor(team): reject legacy guide assistant aliases
Jun 18, 2026
cffae6d
test(team): align app e2e with assistant responses
Jun 18, 2026
5250f35
refactor(team): omit agent types for assistant conversations
Jun 18, 2026
c9f5228
refactor(channel): reject legacy assistant write fields
Jun 18, 2026
2eb2552
refactor(team): reject backend in public write dto
Jun 18, 2026
5a9ec23
refactor(cron): split read and write agent config dto
Jun 18, 2026
40f2a31
refactor(channel): reject unresolved assistant bindings
Jun 18, 2026
79e95ec
feat(agent): add command/env override columns to agent_metadata
Jun 18, 2026
d037fb1
feat(agent): repo read/write for command and env overrides
Jun 18, 2026
3a15f1f
fix(conversation): prefer assistant runtime seeds on create
Jun 18, 2026
f5d049e
feat(agent): add env override key blocklist
Jun 18, 2026
92cc22d
feat(agent): merge command/env overrides at row projection
Jun 18, 2026
80def58
feat(agent): expose override summary fields on management row
Jun 18, 2026
f8b10bb
feat(agent): add overrides endpoint and service
Jun 18, 2026
5e10d8e
fix(team): accept guide JSON success payloads
Jun 18, 2026
6039804
fix(team): accept guide JSON success payloads
Jun 18, 2026
2970ff6
feat(agent): distinguish needs_auth from unavailable status
Jun 18, 2026
e96e18e
merge: resolve probe cleanup and team guide updates
Jun 18, 2026
0fa143c
refactor(team): finish assistant-first guide prompts
Jun 18, 2026
b2edf58
refactor(team): sync solo guide prompt with assistant-first copy
Jun 18, 2026
e11141b
fix(agent): implement update_agent_overrides in test stubs
Jun 18, 2026
48eecf0
fix(team): add override fields to AgentMetadataRow test constructors
Jun 18, 2026
1c22001
refactor(channel): default to bare assistant bindings
Jun 18, 2026
0260b83
fix(team): close assistant-first guide handoff
Jun 18, 2026
2737553
fix(agent): stop leaking override env on management list
Jun 18, 2026
7e7dc4d
fix(agent): clear needs_auth on session success
Jun 18, 2026
ea7893b
Merge branch 'feat/agent-self-repair' into feat/agent-connection-test…
Jun 18, 2026
c4072a9
fix(team): structure assistant-first errors for i18n
Jun 18, 2026
3ed793f
fix(assistant): fall back to agent_type for empty bare backend
Jun 18, 2026
86e8d70
fix(cron): resolve assistant backend from snapshots
Jun 22, 2026
019fc66
refactor(agent): replace available/unavailable/needs_auth with online…
Jun 22, 2026
d279371
feat(agent): probe session/new to detect auth and gate aionrs on prov…
Jun 22, 2026
a4f437e
chore(merge): merge origin main into agent connection branch
Jun 22, 2026
9f4d84a
Merge remote-tracking branch 'origin/main' into feat/agent-connection…
Jun 22, 2026
8f170cc
Merge remote-tracking branch 'origin/main' into feat/agent-connection…
Jun 22, 2026
5456a25
Merge branch 'feat/agent-connection-testing-phase2' of github.com:iOf…
Jun 22, 2026
82f848e
fix(team): bootstrap TeamRun for assistant-first creation
Jun 22, 2026
5f05288
fix(assistant): resolve aionrs agent status by agent_type, not just b…
Jun 22, 2026
65159ae
chore: apply auto-fixes (fmt + clippy)
Jun 22, 2026
0eefd92
fix(ci): stabilize agent availability checks
Jun 22, 2026
5380220
Merge remote-tracking branch 'origin/main' into feat/agent-connection…
Jun 23, 2026
cee9c09
fix(assistant): unify assistant agent id storage
Jun 23, 2026
c74e32b
perf(agent): remove background availability probes
Jun 23, 2026
237eb92
refactor(assistant): normalize assistant and cron identity
Jun 23, 2026
3cb4334
Merge remote-tracking branch 'origin/main' into feat/agent-connection…
Jun 23, 2026
1b737a2
chore: apply auto-fixes (fmt + clippy)
Jun 23, 2026
607a409
test(cron): align workspace e2e fixtures with assistant config
Jun 23, 2026
643be6b
fix(assistant): include agent id in response projections
Jun 23, 2026
f50eb38
chore: apply auto-fixes (fmt + clippy)
Jun 23, 2026
6af8c1d
chore: apply auto-fixes (fmt + clippy)
Jun 24, 2026
3115845
refactor(assistant): expose acp backend explicitly
Jun 24, 2026
2a13562
refactor(assistant): remove duplicate agent id from response
Jun 24, 2026
69184e1
chore: apply auto-fixes (fmt + clippy)
Jun 24, 2026
c637666
Merge remote-tracking branch 'origin/main' into feat/agent-connection…
Jun 24, 2026
b751337
Merge remote-tracking branch 'origin/main' into feat/agent-connection…
Jun 25, 2026
c634999
fix(db): preserve assistant identity during migration
Jun 25, 2026
e2ee532
docs: document bedrock sso expired error
Jun 25, 2026
c29a111
Revert "docs: document bedrock sso expired error"
Jun 25, 2026
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
110 changes: 96 additions & 14 deletions crates/aionui-ai-agent/src/capability/cli_process/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,38 +92,44 @@ impl CliAgentProcess {
}
}

/// Gracefully terminate the subprocess.
/// Gracefully terminate the subprocess **and any descendants in its
/// process group**.
///
/// 1. Close stdin
/// 2. Wait up to `grace_period` for the process to exit on its own
/// 3. If still running after grace period, send SIGKILL
/// 2. Wait up to `grace_period` for the leader to exit on its own
/// 3. SIGKILL the whole process group regardless of whether the leader
/// has already exited — wrapper CLIs (`npm exec ...`) routinely fork
/// a grandchild (`openclaw-acp`) that survives leader exit, and only
/// a group-wide kill reaps it
pub async fn kill(&self, grace_period: Duration) -> Result<(), AgentError> {
// Close stdin first to signal the child
self.close_stdin().await;

// Wait for graceful exit within the grace period
// Wait up to the grace period for the leader to exit on its own.
// Even if it does, we still issue a group-wide SIGKILL below — the
// leader exiting tells us nothing about its grandchildren.
let mut rx = self.exit_rx.clone();
let exited = tokio::time::timeout(grace_period, async {
// If already exited, return immediately
let _ = tokio::time::timeout(grace_period, async {
if rx.borrow().is_some() {
return;
}
// Wait for state change
let _ = rx.changed().await;
})
.await;

if exited.is_ok() && self.exit_rx.borrow().is_some() {
debug!(pid = self.pid, "CLI process exited gracefully");
return Ok(());
// Always sweep the process group. `force_kill` treats ESRCH as
// success, so this is idempotent when the leader (and group) are
// already gone.
if self.exit_rx.borrow().is_some() {
debug!(pid = self.pid, "CLI leader already exited; sweeping process group");
} else {
warn!(pid = self.pid, "Grace period expired, sending SIGKILL");
}

// Force kill
warn!(pid = self.pid, "Grace period expired, sending SIGKILL");
force_kill(self.pid, self.process_group_id)?;

// Wait for the exit monitor to observe process termination so callers
// do not race a still-live child after force-kill returns.
// do not race a still-live leader after force-kill returns. Skip the
// wait if the leader had already exited before our sweep.
let mut rx = self.exit_rx.clone();
tokio::time::timeout(Duration::from_secs(5), async {
if rx.borrow().is_some() {
Expand All @@ -137,6 +143,24 @@ impl CliAgentProcess {
Ok(())
}

/// Unconditionally force-kill this process and its entire process group.
///
/// Unlike [`kill`](Self::kill), this neither closes stdin first nor waits
/// for a graceful exit, and it does **not** short-circuit when the direct
/// child has already exited. It always signals the process *group*, so a
/// descendant reparented to init after the launcher exited (e.g. an
/// npx-spawned ACP grandchild) is still reaped.
///
/// Used by throwaway probe connections: the node/npx launcher exits on its
/// own once the ACP transport closes, but `kill_on_drop` reaps only the
/// direct child, leaving the grandchild (`codex-acp`, `codebuddy --acp`, …)
/// to leak as an orphan.
pub fn force_kill_tree(&self) {
if let Err(e) = force_kill(self.pid, self.process_group_id) {
warn!(pid = self.pid, error = %e, "force_kill_tree failed");
}
}

/// Check whether the subprocess is still running.
#[allow(dead_code)] // Complete CliProcess lifecycle API
pub fn is_running(&self) -> bool {
Expand Down Expand Up @@ -449,6 +473,64 @@ pub(super) mod tests {
assert!(result.is_err());
}

#[cfg(unix)]
#[tokio::test]
async fn force_kill_tree_reaps_grandchild_after_leader_exits() {
// Reproduces the probe leak: the spawned launcher backgrounds a
// long-lived grandchild then exits 0 on its own (mirrors node/npx
// forking the real ACP binary then returning once the transport
// closes). `kill_on_drop` would only reap the direct child; the
// grandchild reparents to init and leaks. `force_kill_tree` must
// signal the whole process group and take the grandchild with it.
let marker = tempfile::NamedTempFile::new().unwrap();
let marker_path = marker.path().to_string_lossy().into_owned();

let config = CommandSpec {
command: "sh".into(),
args: vec![
"-c".into(),
"sleep 60 & child=$!; printf '%s' \"$child\" > \"$1\"; exit 0".into(),
"probe-grandchild-cleanup".into(),
marker_path.clone(),
],
env: vec![],
cwd: None,
};
let proc = spawn_sdk_test_process(config).await;

// Leader exits on its own; wait for the exit monitor to observe it.
timeout(Duration::from_secs(5), proc.wait_for_exit())
.await
.expect("leader should exit promptly");

let child_pid: u32 = std::fs::read_to_string(marker.path())
.expect("grandchild pid marker should exist")
.trim()
.parse()
.expect("grandchild pid should be numeric");

fn is_pid_alive(pid: u32) -> bool {
let result = unsafe { libc::kill(pid as i32, 0) };
if result == 0 {
return true;
}
!matches!(std::io::Error::last_os_error().raw_os_error(), Some(libc::ESRCH))
}

assert!(is_pid_alive(child_pid), "grandchild pid={child_pid} should be alive");

proc.force_kill_tree();

let deadline = std::time::Instant::now() + Duration::from_secs(5);
while is_pid_alive(child_pid) && std::time::Instant::now() < deadline {
tokio::time::sleep(Duration::from_millis(50)).await;
}
assert!(
!is_pid_alive(child_pid),
"grandchild pid={child_pid} should be reaped by force_kill_tree",
);
}

#[tokio::test]
async fn pid_is_nonzero_for_valid_process() {
let config = simple_script_config("sleep 10");
Expand Down
12 changes: 12 additions & 0 deletions crates/aionui-ai-agent/src/factory/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -677,7 +677,19 @@ mod tests {
yolo_id: None,
sort_order: 0,
team_capable: false,
last_check_status: None,
last_check_kind: None,
last_check_error_code: None,
last_check_error_message: None,
last_check_error_details: None,
last_check_guidance: None,
last_check_latency_ms: None,
last_check_at: None,
last_success_at: None,
last_failure_at: None,
handshake: aionui_api_types::AgentHandshake::default(),
has_command_override: false,
env_override_key_count: 0,
};

let spec = resolve_agent_command_spec(
Expand Down
18 changes: 15 additions & 3 deletions crates/aionui-ai-agent/src/factory/acp_assembler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,11 +197,11 @@ mod tests {
let prompt = result.unwrap();
assert!(prompt.contains("aion_create_team"));
assert!(prompt.contains("aion_list_models"));
assert!(prompt.contains("hand off to the created Team conversation"));
assert!(!prompt.contains("Immediately"));
assert!(!prompt.contains(
assert!(prompt.contains("only use returned assistant_id values with `team_spawn_agent`"));
assert!(prompt.contains(
"use team tools (`team_spawn_agent`, `team_send_message`, `team_members`, `team_task_create`, etc.) to manage your team"
));
assert!(!prompt.contains("hand off to the created Team conversation"));
}

#[test]
Expand Down Expand Up @@ -247,7 +247,19 @@ mod tests {
yolo_id: None,
sort_order: 0,
team_capable: true,
last_check_status: None,
last_check_kind: None,
last_check_error_code: None,
last_check_error_message: None,
last_check_error_details: None,
last_check_guidance: None,
last_check_latency_ms: None,
last_check_at: None,
last_success_at: None,
last_failure_at: None,
handshake: aionui_api_types::AgentHandshake::default(),
has_command_override: false,
env_override_key_count: 0,
}
}

Expand Down
6 changes: 3 additions & 3 deletions crates/aionui-ai-agent/src/factory/aionrs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1040,11 +1040,11 @@ mod tests {
let prompt = overrides.system_prompt.as_deref().unwrap();
assert!(prompt.contains("aion_create_team"));
assert!(prompt.contains("aion_list_models"));
assert!(prompt.contains("hand off to the created Team conversation"));
assert!(!prompt.contains("Immediately"));
assert!(!prompt.contains(
assert!(prompt.contains("only use returned assistant_id values with `team_spawn_agent`"));
assert!(prompt.contains(
"use team tools (`team_spawn_agent`, `team_send_message`, `team_members`, `team_task_create`, etc.) to manage your team"
));
assert!(!prompt.contains("hand off to the created Team conversation"));
}

#[test]
Expand Down
1 change: 1 addition & 0 deletions crates/aionui-ai-agent/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ pub use protocol::events::AgentStreamEvent;
pub use protocol::send_error::AgentSendError;
pub use registry::{AgentRegistry, UnavailableReason};
pub use routes::{AgentRouterState, RemoteAgentRouterState, agent_routes, remote_agent_routes};
pub use services::AgentAvailabilityFeedbackPort;
pub use services::AgentService;
pub use services::RemoteAgentService;
pub use session_context::{
Expand Down
12 changes: 12 additions & 0 deletions crates/aionui-ai-agent/src/manager/acp/codex_sandbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,19 @@ mod tests {
yolo_id: Some("full-access".into()),
sort_order: 3110,
team_capable: true,
last_check_status: None,
last_check_kind: None,
last_check_error_code: None,
last_check_error_message: None,
last_check_error_details: None,
last_check_guidance: None,
last_check_latency_ms: None,
last_check_at: None,
last_success_at: None,
last_failure_at: None,
handshake: aionui_api_types::AgentHandshake::default(),
has_command_override: false,
env_override_key_count: 0,
}
}

Expand Down
12 changes: 12 additions & 0 deletions crates/aionui-ai-agent/src/manager/acp/mode_normalize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,19 @@ mod tests {
yolo_id: yolo_id.map(ToOwned::to_owned),
sort_order: 3130,
team_capable: false,
last_check_status: None,
last_check_kind: None,
last_check_error_code: None,
last_check_error_message: None,
last_check_error_details: None,
last_check_guidance: None,
last_check_latency_ms: None,
last_check_at: None,
last_success_at: None,
last_failure_at: None,
handshake: AgentHandshake::default(),
has_command_override: false,
env_override_key_count: 0,
}
}

Expand Down
1 change: 0 additions & 1 deletion crates/aionui-ai-agent/src/persistence/acp_session_sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,6 @@ mod tests {
let repo: Arc<dyn IAcpSessionRepository> = Arc::new(SqliteAcpSessionRepository::new(db.pool().clone()));
repo.create(&CreateAcpSessionParams {
conversation_id: "conv-1",
agent_backend: "claude",
agent_source: "builtin",
agent_id: "2d23ff1c",
})
Expand Down
80 changes: 72 additions & 8 deletions crates/aionui-ai-agent/src/protocol/cli_detect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,33 @@ use std::sync::Arc;
use std::time::Instant;

use crate::registry::AgentRegistry;
use aionui_api_types::{AcpHealthCheckResponse, AgentMetadata};
use aionui_api_types::AgentMetadata;
use aionui_runtime::resolve_command_path;

pub(crate) struct CliHealthCheckResult {
pub available: bool,
pub error: Option<String>,
}

/// Perform a health check for an ACP backend.
///
/// Checks CLI availability and measures detection latency.
pub(crate) async fn health_check(registry: &Arc<AgentRegistry>, backend: &str) -> AcpHealthCheckResponse {
/// Checks CLI availability and returns an availability/error pair.
pub(crate) async fn health_check(registry: &Arc<AgentRegistry>, backend: &str) -> CliHealthCheckResult {
let start = Instant::now();

let Some(meta) = registry.find_builtin_by_backend(backend).await else {
return AcpHealthCheckResponse {
return CliHealthCheckResult {
available: false,
latency: None,
error: Some(format!("No agent_metadata row for backend '{backend}'")),
};
};

let path = probe_command(&meta);
let latency_ms = start.elapsed().as_millis() as u64;
let _latency_ms = start.elapsed().as_millis() as u64;
let available = path.is_some();

AcpHealthCheckResponse {
CliHealthCheckResult {
available,
latency: Some(latency_ms),
error: if available {
None
} else {
Expand All @@ -35,6 +38,67 @@ pub(crate) async fn health_check(registry: &Arc<AgentRegistry>, backend: &str) -
}

fn probe_command(meta: &AgentMetadata) -> Option<String> {
if let Some(path) = meta.resolved_command.as_ref() {
return Some(path.to_string_lossy().into_owned());
}
let cmd = meta.command.as_deref()?;
resolve_command_path(cmd).map(|p| p.to_string_lossy().into_owned())
}

#[cfg(test)]
mod tests {
use super::*;
use aionui_api_types::{
AgentHandshake, AgentSnapshotCheckKind, AgentSnapshotCheckStatus, AgentSource, AgentSourceInfo, BehaviorPolicy,
};
use aionui_common::AgentType;
use std::path::PathBuf;

fn metadata_with_resolved_command() -> AgentMetadata {
AgentMetadata {
id: "agent-codex".into(),
icon: None,
name: "Codex CLI".into(),
name_i18n: None,
description: None,
description_i18n: None,
backend: Some("codex".into()),
agent_type: AgentType::Acp,
agent_source: AgentSource::Builtin,
agent_source_info: AgentSourceInfo {
binary_name: Some("codex".into()),
..Default::default()
},
enabled: true,
available: true,
command: None,
resolved_command: Some(PathBuf::from("codex-acp")),
args: vec![],
env: vec![],
native_skills_dirs: None,
behavior_policy: BehaviorPolicy::default(),
yolo_id: None,
sort_order: 0,
team_capable: false,
last_check_status: Some(AgentSnapshotCheckStatus::Online),
last_check_kind: Some(AgentSnapshotCheckKind::Startup),
last_check_error_code: None,
last_check_error_message: None,
last_check_error_details: None,
last_check_guidance: None,
last_check_latency_ms: None,
last_check_at: None,
last_success_at: None,
last_failure_at: None,
handshake: AgentHandshake::default(),
has_command_override: false,
env_override_key_count: 0,
}
}

#[test]
fn probe_command_uses_hydrated_resolved_command_when_spawn_command_is_empty() {
let meta = metadata_with_resolved_command();
assert_eq!(probe_command(&meta), Some("codex-acp".into()));
}
}
Loading
Loading