mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-21 12:00:23 +00:00
fix: correct inspector package name in Dockerfiles and add .dockerignore (#50)
* chore: remove inspect.sandboxagent.dev in favor of /ui/ * chore: add 404 page * fix: correct inspector package name in Dockerfiles and add .dockerignore - Change @anthropic-ai/sdk-inspector to @sandbox-agent/inspector in all Dockerfiles - Add .dockerignore to exclude target/, node_modules/, etc from Docker context The wrong package name caused pnpm install --filter to match nothing, so the inspector frontend was never built, resulting in binaries without the /ui/ endpoint. * chore: cargo fmt * chore(release): update version to 0.1.4-rc.7
This commit is contained in:
parent
cacb63ef17
commit
e3c030f66d
57 changed files with 927 additions and 771 deletions
|
|
@ -3,8 +3,8 @@ use std::io::{BufRead, BufReader};
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use sandbox_agent_error::SandboxError;
|
||||
use time::{Duration, OffsetDateTime};
|
||||
use sandbox_agent_universal_agent_schema::StderrOutput;
|
||||
use time::{Duration, OffsetDateTime};
|
||||
|
||||
const LOG_RETENTION_DAYS: i64 = 7;
|
||||
const LOG_HEAD_LINES: usize = 20;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
//! Sandbox agent core utilities.
|
||||
|
||||
pub mod credentials;
|
||||
mod agent_server_logs;
|
||||
pub mod credentials;
|
||||
pub mod router;
|
||||
pub mod telemetry;
|
||||
pub mod ui;
|
||||
|
|
|
|||
|
|
@ -79,10 +79,6 @@ struct ServerArgs {
|
|||
#[arg(long = "cors-allow-credentials", short = 'C')]
|
||||
cors_allow_credentials: bool,
|
||||
|
||||
/// Disable default CORS for the inspector (https://inspect.sandboxagent.dev)
|
||||
#[arg(long = "no-inspector-cors")]
|
||||
no_inspector_cors: bool,
|
||||
|
||||
#[arg(long = "no-telemetry")]
|
||||
no_telemetry: bool,
|
||||
}
|
||||
|
|
@ -848,19 +844,11 @@ fn available_providers(credentials: &ExtractedCredentials) -> Vec<String> {
|
|||
providers
|
||||
}
|
||||
|
||||
const INSPECTOR_ORIGIN: &str = "https://inspect.sandboxagent.dev";
|
||||
|
||||
fn build_cors_layer(server: &ServerArgs) -> Result<CorsLayer, CliError> {
|
||||
let mut cors = CorsLayer::new();
|
||||
|
||||
// Build origins list: inspector by default + any additional origins
|
||||
// Build origins list from provided origins
|
||||
let mut origins = Vec::new();
|
||||
if !server.no_inspector_cors {
|
||||
let inspector_origin = INSPECTOR_ORIGIN
|
||||
.parse()
|
||||
.map_err(|_| CliError::InvalidCorsOrigin(INSPECTOR_ORIGIN.to_string()))?;
|
||||
origins.push(inspector_origin);
|
||||
}
|
||||
for origin in &server.cors_allow_origin {
|
||||
let value = origin
|
||||
.parse()
|
||||
|
|
|
|||
|
|
@ -25,8 +25,7 @@ use sandbox_agent_universal_agent_schema::{
|
|||
ItemDeltaData, ItemEventData, ItemKind, ItemRole, ItemStatus, PermissionEventData,
|
||||
PermissionStatus, QuestionEventData, QuestionStatus, ReasoningVisibility, SessionEndReason,
|
||||
SessionEndedData, SessionStartedData, StderrOutput, TerminatedBy, UniversalEvent,
|
||||
UniversalEventData,
|
||||
UniversalEventType, UniversalItem,
|
||||
UniversalEventData, UniversalEventType, UniversalItem,
|
||||
};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
@ -37,6 +36,7 @@ use tokio_stream::wrappers::BroadcastStream;
|
|||
use tower_http::trace::TraceLayer;
|
||||
use utoipa::{Modify, OpenApi, ToSchema};
|
||||
|
||||
use crate::agent_server_logs::AgentServerLogs;
|
||||
use crate::ui;
|
||||
use sandbox_agent_agent_management::agents::{
|
||||
AgentError as ManagerError, AgentId, AgentManager, InstallOptions, SpawnOptions, StreamingSpawn,
|
||||
|
|
@ -44,7 +44,6 @@ use sandbox_agent_agent_management::agents::{
|
|||
use sandbox_agent_agent_management::credentials::{
|
||||
extract_all_credentials, CredentialExtractionOptions, ExtractedCredentials,
|
||||
};
|
||||
use crate::agent_server_logs::AgentServerLogs;
|
||||
|
||||
const MOCK_EVENT_DELAY_MS: u64 = 200;
|
||||
static USER_MESSAGE_COUNTER: AtomicU64 = AtomicU64::new(1);
|
||||
|
|
@ -99,7 +98,10 @@ pub fn build_router_with_state(shared: Arc<AppState>) -> (Router, Arc<AppState>)
|
|||
.route("/sessions", get(list_sessions))
|
||||
.route("/sessions/:session_id", post(create_session))
|
||||
.route("/sessions/:session_id/messages", post(post_message))
|
||||
.route("/sessions/:session_id/messages/stream", post(post_message_stream))
|
||||
.route(
|
||||
"/sessions/:session_id/messages/stream",
|
||||
post(post_message_stream),
|
||||
)
|
||||
.route("/sessions/:session_id/terminate", post(terminate_session))
|
||||
.route("/sessions/:session_id/events", get(get_events))
|
||||
.route("/sessions/:session_id/events/sse", get(get_events_sse))
|
||||
|
|
@ -126,7 +128,8 @@ pub fn build_router_with_state(shared: Arc<AppState>) -> (Router, Arc<AppState>)
|
|||
|
||||
let mut router = Router::new()
|
||||
.route("/", get(get_root))
|
||||
.nest("/v1", v1_router);
|
||||
.nest("/v1", v1_router)
|
||||
.fallback(not_found);
|
||||
|
||||
if ui::is_enabled() {
|
||||
router = router.merge(ui::router());
|
||||
|
|
@ -1141,8 +1144,8 @@ impl AgentServerManager {
|
|||
) -> Result<(String, Arc<std::sync::Mutex<Option<std::process::Child>>>), SandboxError> {
|
||||
let manager = self.agent_manager.clone();
|
||||
let log_dir = self.log_base_dir.clone();
|
||||
let (base_url, child) =
|
||||
tokio::task::spawn_blocking(move || -> Result<(String, std::process::Child), SandboxError> {
|
||||
let (base_url, child) = tokio::task::spawn_blocking(
|
||||
move || -> Result<(String, std::process::Child), SandboxError> {
|
||||
let path = manager
|
||||
.resolve_binary(agent)
|
||||
.map_err(|err| map_spawn_error(agent, err))?;
|
||||
|
|
@ -1159,16 +1162,14 @@ impl AgentServerManager {
|
|||
message: err.to_string(),
|
||||
})?;
|
||||
Ok((format!("http://127.0.0.1:{port}"), child))
|
||||
})
|
||||
.await
|
||||
.map_err(|err| SandboxError::StreamError {
|
||||
message: err.to_string(),
|
||||
})??;
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|err| SandboxError::StreamError {
|
||||
message: err.to_string(),
|
||||
})??;
|
||||
|
||||
Ok((
|
||||
base_url,
|
||||
Arc::new(std::sync::Mutex::new(Some(child))),
|
||||
))
|
||||
Ok((base_url, Arc::new(std::sync::Mutex::new(Some(child)))))
|
||||
}
|
||||
|
||||
async fn spawn_stdio_server(
|
||||
|
|
@ -1187,59 +1188,66 @@ impl AgentServerManager {
|
|||
let (stdin_tx, stdin_rx) = mpsc::unbounded_channel::<String>();
|
||||
let (stdout_tx, stdout_rx) = mpsc::unbounded_channel::<String>();
|
||||
|
||||
let child = tokio::task::spawn_blocking(move || -> Result<std::process::Child, SandboxError> {
|
||||
let path = manager
|
||||
.resolve_binary(agent)
|
||||
.map_err(|err| map_spawn_error(agent, err))?;
|
||||
let mut command = std::process::Command::new(path);
|
||||
let stderr = AgentServerLogs::new(log_dir, agent.as_str()).open()?;
|
||||
command
|
||||
.arg("app-server")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(stderr);
|
||||
let child =
|
||||
tokio::task::spawn_blocking(move || -> Result<std::process::Child, SandboxError> {
|
||||
let path = manager
|
||||
.resolve_binary(agent)
|
||||
.map_err(|err| map_spawn_error(agent, err))?;
|
||||
let mut command = std::process::Command::new(path);
|
||||
let stderr = AgentServerLogs::new(log_dir, agent.as_str()).open()?;
|
||||
command
|
||||
.arg("app-server")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(stderr);
|
||||
|
||||
let mut child = command.spawn().map_err(|err| SandboxError::StreamError {
|
||||
let mut child = command.spawn().map_err(|err| SandboxError::StreamError {
|
||||
message: err.to_string(),
|
||||
})?;
|
||||
|
||||
let stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| SandboxError::StreamError {
|
||||
message: "codex stdin unavailable".to_string(),
|
||||
})?;
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| SandboxError::StreamError {
|
||||
message: "codex stdout unavailable".to_string(),
|
||||
})?;
|
||||
|
||||
let stdin_rx_mut = std::sync::Mutex::new(stdin_rx);
|
||||
std::thread::spawn(move || {
|
||||
let mut stdin = stdin;
|
||||
let mut rx = stdin_rx_mut.lock().unwrap();
|
||||
while let Some(line) = rx.blocking_recv() {
|
||||
if writeln!(stdin, "{line}").is_err() {
|
||||
break;
|
||||
}
|
||||
if stdin.flush().is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let reader = BufReader::new(stdout);
|
||||
for line in reader.lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if stdout_tx.send(line).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(child)
|
||||
})
|
||||
.await
|
||||
.map_err(|err| SandboxError::StreamError {
|
||||
message: err.to_string(),
|
||||
})?;
|
||||
|
||||
let stdin = child.stdin.take().ok_or_else(|| SandboxError::StreamError {
|
||||
message: "codex stdin unavailable".to_string(),
|
||||
})?;
|
||||
let stdout = child.stdout.take().ok_or_else(|| SandboxError::StreamError {
|
||||
message: "codex stdout unavailable".to_string(),
|
||||
})?;
|
||||
|
||||
let stdin_rx_mut = std::sync::Mutex::new(stdin_rx);
|
||||
std::thread::spawn(move || {
|
||||
let mut stdin = stdin;
|
||||
let mut rx = stdin_rx_mut.lock().unwrap();
|
||||
while let Some(line) = rx.blocking_recv() {
|
||||
if writeln!(stdin, "{line}").is_err() {
|
||||
break;
|
||||
}
|
||||
if stdin.flush().is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let reader = BufReader::new(stdout);
|
||||
for line in reader.lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if stdout_tx.send(line).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(child)
|
||||
})
|
||||
.await
|
||||
.map_err(|err| SandboxError::StreamError {
|
||||
message: err.to_string(),
|
||||
})??;
|
||||
})??;
|
||||
|
||||
let server = Arc::new(CodexServer::new(stdin_tx));
|
||||
|
||||
|
|
@ -1347,7 +1355,10 @@ impl AgentServerManager {
|
|||
}
|
||||
}
|
||||
|
||||
async fn ensure_server_for_restart(self: Arc<Self>, agent: AgentId) -> Result<(), SandboxError> {
|
||||
async fn ensure_server_for_restart(
|
||||
self: Arc<Self>,
|
||||
agent: AgentId,
|
||||
) -> Result<(), SandboxError> {
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
match agent {
|
||||
AgentId::Opencode => {
|
||||
|
|
@ -1445,26 +1456,24 @@ impl SessionManager {
|
|||
}
|
||||
}
|
||||
|
||||
fn session_ref<'a>(
|
||||
sessions: &'a [SessionState],
|
||||
session_id: &str,
|
||||
) -> Option<&'a SessionState> {
|
||||
sessions.iter().find(|session| session.session_id == session_id)
|
||||
fn session_ref<'a>(sessions: &'a [SessionState], session_id: &str) -> Option<&'a SessionState> {
|
||||
sessions
|
||||
.iter()
|
||||
.find(|session| session.session_id == session_id)
|
||||
}
|
||||
|
||||
fn session_mut<'a>(
|
||||
sessions: &'a mut [SessionState],
|
||||
session_id: &str,
|
||||
) -> Option<&'a mut SessionState> {
|
||||
sessions.iter_mut().find(|session| session.session_id == session_id)
|
||||
sessions
|
||||
.iter_mut()
|
||||
.find(|session| session.session_id == session_id)
|
||||
}
|
||||
|
||||
/// Read agent stderr for error diagnostics
|
||||
fn read_agent_stderr(&self, agent: AgentId) -> Option<StderrOutput> {
|
||||
let logs = AgentServerLogs::new(
|
||||
self.server_manager.log_base_dir.clone(),
|
||||
agent.as_str(),
|
||||
);
|
||||
let logs = AgentServerLogs::new(self.server_manager.log_base_dir.clone(), agent.as_str());
|
||||
logs.read_stderr()
|
||||
}
|
||||
|
||||
|
|
@ -1476,7 +1485,10 @@ impl SessionManager {
|
|||
let agent_id = parse_agent_id(&request.agent)?;
|
||||
{
|
||||
let sessions = self.sessions.lock().await;
|
||||
if sessions.iter().any(|session| session.session_id == session_id) {
|
||||
if sessions
|
||||
.iter()
|
||||
.any(|session| session.session_id == session_id)
|
||||
{
|
||||
return Err(SandboxError::SessionAlreadyExists { session_id });
|
||||
}
|
||||
}
|
||||
|
|
@ -1675,10 +1687,7 @@ impl SessionManager {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn emit_synthetic_assistant_start(
|
||||
&self,
|
||||
session_id: &str,
|
||||
) -> Result<(), SandboxError> {
|
||||
async fn emit_synthetic_assistant_start(&self, session_id: &str) -> Result<(), SandboxError> {
|
||||
let conversion = {
|
||||
let mut sessions = self.sessions.lock().await;
|
||||
let session = Self::session_mut(&mut sessions, session_id).ok_or_else(|| {
|
||||
|
|
@ -1688,7 +1697,9 @@ impl SessionManager {
|
|||
})?;
|
||||
session.enqueue_pending_assistant_start()
|
||||
};
|
||||
let _ = self.record_conversions(session_id, vec![conversion]).await?;
|
||||
let _ = self
|
||||
.record_conversions(session_id, vec![conversion])
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -1905,12 +1916,16 @@ impl SessionManager {
|
|||
let sender = claude_sender.ok_or_else(|| SandboxError::InvalidRequest {
|
||||
message: "Claude session is not active".to_string(),
|
||||
})?;
|
||||
let session_id = native_session_id.clone().unwrap_or_else(|| session_id.to_string());
|
||||
let session_id = native_session_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| session_id.to_string());
|
||||
let response_text = response.clone().unwrap_or_default();
|
||||
let line = claude_tool_result_line(&session_id, question_id, &response_text, false);
|
||||
sender.send(line).map_err(|_| SandboxError::InvalidRequest {
|
||||
message: "Claude session is not active".to_string(),
|
||||
})?;
|
||||
sender
|
||||
.send(line)
|
||||
.map_err(|_| SandboxError::InvalidRequest {
|
||||
message: "Claude session is not active".to_string(),
|
||||
})?;
|
||||
} else {
|
||||
// TODO: Forward question replies to subprocess agents.
|
||||
}
|
||||
|
|
@ -1976,16 +1991,20 @@ impl SessionManager {
|
|||
let sender = claude_sender.ok_or_else(|| SandboxError::InvalidRequest {
|
||||
message: "Claude session is not active".to_string(),
|
||||
})?;
|
||||
let session_id = native_session_id.clone().unwrap_or_else(|| session_id.to_string());
|
||||
let session_id = native_session_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| session_id.to_string());
|
||||
let line = claude_tool_result_line(
|
||||
&session_id,
|
||||
question_id,
|
||||
"User rejected the question.",
|
||||
true,
|
||||
);
|
||||
sender.send(line).map_err(|_| SandboxError::InvalidRequest {
|
||||
message: "Claude session is not active".to_string(),
|
||||
})?;
|
||||
sender
|
||||
.send(line)
|
||||
.map_err(|_| SandboxError::InvalidRequest {
|
||||
message: "Claude session is not active".to_string(),
|
||||
})?;
|
||||
} else {
|
||||
// TODO: Forward question rejections to subprocess agents.
|
||||
}
|
||||
|
|
@ -2139,9 +2158,11 @@ impl SessionManager {
|
|||
};
|
||||
|
||||
let line = claude_control_response_line(permission_id, behavior, response_value);
|
||||
sender.send(line).map_err(|_| SandboxError::InvalidRequest {
|
||||
message: "Claude session is not active".to_string(),
|
||||
})?;
|
||||
sender
|
||||
.send(line)
|
||||
.map_err(|_| SandboxError::InvalidRequest {
|
||||
message: "Claude session is not active".to_string(),
|
||||
})?;
|
||||
} else {
|
||||
// TODO: Forward permission replies to subprocess agents.
|
||||
}
|
||||
|
|
@ -2811,7 +2832,8 @@ impl SessionManager {
|
|||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let message: codex_schema::JsonrpcMessage = match serde_json::from_value(value.clone()) {
|
||||
let message: codex_schema::JsonrpcMessage = match serde_json::from_value(value.clone())
|
||||
{
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
|
@ -2828,12 +2850,17 @@ impl SessionManager {
|
|||
if let Ok(notification) =
|
||||
serde_json::from_value::<codex_schema::ServerNotification>(value.clone())
|
||||
{
|
||||
if let Some(thread_id) = codex_thread_id_from_server_notification(¬ification) {
|
||||
if let Some(thread_id) =
|
||||
codex_thread_id_from_server_notification(¬ification)
|
||||
{
|
||||
if let Some(session_id) = server.session_for_thread(&thread_id) {
|
||||
let conversions = match convert_codex::notification_to_universal(¬ification) {
|
||||
Ok(c) => c,
|
||||
Err(err) => vec![agent_unparsed("codex", &err, value.clone())],
|
||||
};
|
||||
let conversions =
|
||||
match convert_codex::notification_to_universal(¬ification) {
|
||||
Ok(c) => c,
|
||||
Err(err) => {
|
||||
vec![agent_unparsed("codex", &err, value.clone())]
|
||||
}
|
||||
};
|
||||
let _ = self.record_conversions(&session_id, conversions).await;
|
||||
}
|
||||
}
|
||||
|
|
@ -2851,7 +2878,8 @@ impl SessionManager {
|
|||
for conversion in &mut conversions {
|
||||
conversion.raw = Some(value.clone());
|
||||
}
|
||||
let _ = self.record_conversions(&session_id, conversions).await;
|
||||
let _ =
|
||||
self.record_conversions(&session_id, conversions).await;
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = self
|
||||
|
|
@ -2982,12 +3010,13 @@ impl SessionManager {
|
|||
) -> Result<(), SandboxError> {
|
||||
let server = self.ensure_codex_server().await?;
|
||||
|
||||
let thread_id = session
|
||||
.native_session_id
|
||||
.as_ref()
|
||||
.ok_or_else(|| SandboxError::InvalidRequest {
|
||||
message: "missing Codex thread id".to_string(),
|
||||
})?;
|
||||
let thread_id =
|
||||
session
|
||||
.native_session_id
|
||||
.as_ref()
|
||||
.ok_or_else(|| SandboxError::InvalidRequest {
|
||||
message: "missing Codex thread id".to_string(),
|
||||
})?;
|
||||
|
||||
let id = server.next_request_id();
|
||||
let prompt_text = codex_prompt_for_mode(prompt, Some(&session.agent_mode));
|
||||
|
|
@ -3430,14 +3459,22 @@ pub struct EventsQuery {
|
|||
pub offset: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub limit: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none", alias = "include_raw")]
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
alias = "include_raw"
|
||||
)]
|
||||
pub include_raw: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TurnStreamQuery {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none", alias = "include_raw")]
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
alias = "include_raw"
|
||||
)]
|
||||
pub include_raw: Option<bool>,
|
||||
}
|
||||
|
||||
|
|
@ -3541,10 +3578,22 @@ async fn get_agent_modes(
|
|||
Ok(Json(AgentModesResponse { modes }))
|
||||
}
|
||||
|
||||
const SERVER_INFO: &str = "\
|
||||
This is a Sandbox Agent server. Available endpoints:\n\
|
||||
- GET / - Server info\n\
|
||||
- GET /v1/health - Health check\n\
|
||||
- GET /ui/ - Inspector UI\n\n\
|
||||
See https://sandboxagent.dev for API documentation.";
|
||||
|
||||
async fn get_root() -> &'static str {
|
||||
"This is a Sandbox Agent server for orchestrating coding agents.\n\
|
||||
See https://sandboxagent.dev for more information.\n\
|
||||
Visit /ui/ for the inspector UI."
|
||||
SERVER_INFO
|
||||
}
|
||||
|
||||
async fn not_found() -> (StatusCode, String) {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
format!("404 Not Found\n\n{SERVER_INFO}"),
|
||||
)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
|
|
@ -3571,48 +3620,47 @@ async fn list_agents(
|
|||
let manager = state.agent_manager.clone();
|
||||
let server_statuses = state.session_manager.server_manager.status_snapshot().await;
|
||||
|
||||
let agents = tokio::task::spawn_blocking(move || {
|
||||
all_agents()
|
||||
.into_iter()
|
||||
.map(|agent_id| {
|
||||
let installed = manager.is_installed(agent_id);
|
||||
let version = manager.version(agent_id).ok().flatten();
|
||||
let path = manager.resolve_binary(agent_id).ok();
|
||||
let capabilities = agent_capabilities_for(agent_id);
|
||||
let agents =
|
||||
tokio::task::spawn_blocking(move || {
|
||||
all_agents()
|
||||
.into_iter()
|
||||
.map(|agent_id| {
|
||||
let installed = manager.is_installed(agent_id);
|
||||
let version = manager.version(agent_id).ok().flatten();
|
||||
let path = manager.resolve_binary(agent_id).ok();
|
||||
let capabilities = agent_capabilities_for(agent_id);
|
||||
|
||||
// Add server_status for agents with shared processes
|
||||
let server_status = if capabilities.shared_process {
|
||||
Some(
|
||||
server_statuses
|
||||
.get(&agent_id)
|
||||
.cloned()
|
||||
.unwrap_or(ServerStatusInfo {
|
||||
status: ServerStatus::Stopped,
|
||||
base_url: None,
|
||||
uptime_ms: None,
|
||||
restart_count: 0,
|
||||
last_error: None,
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Add server_status for agents with shared processes
|
||||
let server_status =
|
||||
if capabilities.shared_process {
|
||||
Some(server_statuses.get(&agent_id).cloned().unwrap_or(
|
||||
ServerStatusInfo {
|
||||
status: ServerStatus::Stopped,
|
||||
base_url: None,
|
||||
uptime_ms: None,
|
||||
restart_count: 0,
|
||||
last_error: None,
|
||||
},
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
AgentInfo {
|
||||
id: agent_id.as_str().to_string(),
|
||||
installed,
|
||||
version,
|
||||
path: path.map(|path| path.to_string_lossy().to_string()),
|
||||
capabilities,
|
||||
server_status,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.await
|
||||
.map_err(|err| SandboxError::StreamError {
|
||||
message: err.to_string(),
|
||||
})?;
|
||||
AgentInfo {
|
||||
id: agent_id.as_str().to_string(),
|
||||
installed,
|
||||
version,
|
||||
path: path.map(|path| path.to_string_lossy().to_string()),
|
||||
capabilities,
|
||||
server_status,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.await
|
||||
.map_err(|err| SandboxError::StreamError {
|
||||
message: err.to_string(),
|
||||
})?;
|
||||
|
||||
Ok(Json(AgentListResponse { agents }))
|
||||
}
|
||||
|
|
@ -3898,7 +3946,10 @@ fn all_agents() -> [AgentId; 5] {
|
|||
/// Returns true if the agent supports resuming a session after its process exits.
|
||||
/// These agents can use --resume/--continue to continue a conversation.
|
||||
fn agent_supports_resume(agent: AgentId) -> bool {
|
||||
matches!(agent, AgentId::Claude | AgentId::Amp | AgentId::Opencode | AgentId::Codex)
|
||||
matches!(
|
||||
agent,
|
||||
AgentId::Claude | AgentId::Amp | AgentId::Opencode | AgentId::Codex
|
||||
)
|
||||
}
|
||||
|
||||
fn agent_supports_item_started(agent: AgentId) -> bool {
|
||||
|
|
@ -4066,16 +4117,18 @@ fn agent_modes_for(agent: AgentId) -> Vec<AgentModeInfo> {
|
|||
name: "Build".to_string(),
|
||||
description: "Default build mode".to_string(),
|
||||
}],
|
||||
AgentId::Mock => vec![AgentModeInfo {
|
||||
id: "build".to_string(),
|
||||
name: "Build".to_string(),
|
||||
description: "Mock agent for UI testing".to_string(),
|
||||
},
|
||||
AgentModeInfo {
|
||||
id: "plan".to_string(),
|
||||
name: "Plan".to_string(),
|
||||
description: "Plan-only mock mode".to_string(),
|
||||
}],
|
||||
AgentId::Mock => vec![
|
||||
AgentModeInfo {
|
||||
id: "build".to_string(),
|
||||
name: "Build".to_string(),
|
||||
description: "Mock agent for UI testing".to_string(),
|
||||
},
|
||||
AgentModeInfo {
|
||||
id: "plan".to_string(),
|
||||
name: "Plan".to_string(),
|
||||
description: "Plan-only mock mode".to_string(),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -4307,16 +4360,9 @@ fn claude_tool_result_line(
|
|||
.to_string()
|
||||
}
|
||||
|
||||
fn claude_control_response_line(
|
||||
request_id: &str,
|
||||
behavior: &str,
|
||||
response: Value,
|
||||
) -> String {
|
||||
fn claude_control_response_line(request_id: &str, behavior: &str, response: Value) -> String {
|
||||
let mut response_obj = serde_json::Map::new();
|
||||
response_obj.insert(
|
||||
"behavior".to_string(),
|
||||
Value::String(behavior.to_string()),
|
||||
);
|
||||
response_obj.insert("behavior".to_string(), Value::String(behavior.to_string()));
|
||||
if let Some(message) = response.get("message") {
|
||||
response_obj.insert("message".to_string(), message.clone());
|
||||
}
|
||||
|
|
@ -5022,7 +5068,8 @@ pub mod test_utils {
|
|||
impl TestHarness {
|
||||
pub async fn new() -> Self {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
let agent_manager = Arc::new(AgentManager::new(temp_dir.path()).expect("agent manager"));
|
||||
let agent_manager =
|
||||
Arc::new(AgentManager::new(temp_dir.path()).expect("agent manager"));
|
||||
let session_manager = Arc::new(SessionManager::new(agent_manager));
|
||||
session_manager
|
||||
.server_manager
|
||||
|
|
@ -5058,11 +5105,7 @@ pub mod test_utils {
|
|||
.await;
|
||||
}
|
||||
|
||||
pub async fn has_session_mapping(
|
||||
&self,
|
||||
agent: AgentId,
|
||||
session_id: &str,
|
||||
) -> bool {
|
||||
pub async fn has_session_mapping(&self, agent: AgentId, session_id: &str) -> bool {
|
||||
let sessions = self.session_manager.server_manager.sessions.lock().await;
|
||||
sessions
|
||||
.get(&agent)
|
||||
|
|
@ -5101,8 +5144,8 @@ pub mod test_utils {
|
|||
variant: None,
|
||||
agent_version: None,
|
||||
};
|
||||
let mut session = SessionState::new(session_id.to_string(), agent, &request)
|
||||
.expect("session");
|
||||
let mut session =
|
||||
SessionState::new(session_id.to_string(), agent, &request).expect("session");
|
||||
session.native_session_id = native_session_id.map(|id| id.to_string());
|
||||
self.session_manager.sessions.lock().await.push(session);
|
||||
}
|
||||
|
|
@ -5116,38 +5159,48 @@ pub mod test_utils {
|
|||
let (stdin_tx, _stdin_rx) = mpsc::unbounded_channel::<String>();
|
||||
let server = Arc::new(CodexServer::new(stdin_tx));
|
||||
let child = Arc::new(std::sync::Mutex::new(child));
|
||||
self.session_manager.server_manager.servers.lock().await.insert(
|
||||
agent,
|
||||
ManagedServer {
|
||||
kind: ManagedServerKind::Stdio { server },
|
||||
child: child.clone(),
|
||||
status: ServerStatus::Running,
|
||||
start_time: Some(Instant::now()),
|
||||
restart_count: 0,
|
||||
last_error: None,
|
||||
shutdown_requested: false,
|
||||
instance_id,
|
||||
},
|
||||
);
|
||||
self.session_manager
|
||||
.server_manager
|
||||
.servers
|
||||
.lock()
|
||||
.await
|
||||
.insert(
|
||||
agent,
|
||||
ManagedServer {
|
||||
kind: ManagedServerKind::Stdio { server },
|
||||
child: child.clone(),
|
||||
status: ServerStatus::Running,
|
||||
start_time: Some(Instant::now()),
|
||||
restart_count: 0,
|
||||
last_error: None,
|
||||
shutdown_requested: false,
|
||||
instance_id,
|
||||
},
|
||||
);
|
||||
child
|
||||
}
|
||||
|
||||
pub async fn insert_http_server(&self, agent: AgentId, instance_id: u64) {
|
||||
self.session_manager.server_manager.servers.lock().await.insert(
|
||||
agent,
|
||||
ManagedServer {
|
||||
kind: ManagedServerKind::Http {
|
||||
base_url: "http://127.0.0.1:1".to_string(),
|
||||
self.session_manager
|
||||
.server_manager
|
||||
.servers
|
||||
.lock()
|
||||
.await
|
||||
.insert(
|
||||
agent,
|
||||
ManagedServer {
|
||||
kind: ManagedServerKind::Http {
|
||||
base_url: "http://127.0.0.1:1".to_string(),
|
||||
},
|
||||
child: Arc::new(std::sync::Mutex::new(None)),
|
||||
status: ServerStatus::Running,
|
||||
start_time: Some(Instant::now()),
|
||||
restart_count: 0,
|
||||
last_error: None,
|
||||
shutdown_requested: false,
|
||||
instance_id,
|
||||
},
|
||||
child: Arc::new(std::sync::Mutex::new(None)),
|
||||
status: ServerStatus::Running,
|
||||
start_time: Some(Instant::now()),
|
||||
restart_count: 0,
|
||||
last_error: None,
|
||||
shutdown_requested: false,
|
||||
instance_id,
|
||||
},
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
pub async fn handle_process_exit(
|
||||
|
|
@ -5236,7 +5289,12 @@ pub mod test_utils {
|
|||
fn default_log_dir() -> PathBuf {
|
||||
dirs::data_dir()
|
||||
.map(|dir| dir.join("sandbox-agent").join("logs").join("servers"))
|
||||
.unwrap_or_else(|| PathBuf::from(".").join(".sandbox-agent").join("logs").join("servers"))
|
||||
.unwrap_or_else(|| {
|
||||
PathBuf::from(".")
|
||||
.join(".sandbox-agent")
|
||||
.join("logs")
|
||||
.join("servers")
|
||||
})
|
||||
}
|
||||
|
||||
fn find_available_port() -> Result<u16, SandboxError> {
|
||||
|
|
@ -5287,7 +5345,6 @@ impl SseAccumulator {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
fn parse_opencode_modes(value: &Value) -> Vec<AgentModeInfo> {
|
||||
let mut modes = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
|
|
|
|||
|
|
@ -90,7 +90,8 @@ pub fn spawn_telemetry_task() {
|
|||
|
||||
attempt_send(&client).await;
|
||||
let start = Instant::now() + Duration::from_secs(TELEMETRY_INTERVAL_SECS);
|
||||
let mut interval = tokio::time::interval_at(start, Duration::from_secs(TELEMETRY_INTERVAL_SECS));
|
||||
let mut interval =
|
||||
tokio::time::interval_at(start, Duration::from_secs(TELEMETRY_INTERVAL_SECS));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
attempt_send(&client).await;
|
||||
|
|
@ -150,7 +151,12 @@ fn load_or_create_id() -> String {
|
|||
}
|
||||
}
|
||||
|
||||
if let Ok(mut file) = fs::OpenOptions::new().create(true).write(true).truncate(true).open(&path) {
|
||||
if let Ok(mut file) = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(&path)
|
||||
{
|
||||
let _ = file.write_all(id.as_bytes());
|
||||
}
|
||||
id
|
||||
|
|
@ -194,7 +200,12 @@ fn write_last_sent(timestamp: i64) {
|
|||
return;
|
||||
}
|
||||
}
|
||||
if let Ok(mut file) = fs::OpenOptions::new().create(true).write(true).truncate(true).open(&path) {
|
||||
if let Ok(mut file) = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(&path)
|
||||
{
|
||||
let _ = file.write_all(timestamp.to_string().as_bytes());
|
||||
}
|
||||
}
|
||||
|
|
@ -266,7 +277,8 @@ fn detect_provider() -> ProviderInfo {
|
|||
};
|
||||
}
|
||||
|
||||
if env::var("MODAL_IS_REMOTE").as_deref() == Ok("1") || env::var("MODAL_CLOUD_PROVIDER").is_ok() {
|
||||
if env::var("MODAL_IS_REMOTE").as_deref() == Ok("1") || env::var("MODAL_CLOUD_PROVIDER").is_ok()
|
||||
{
|
||||
let metadata = metadata_or_none([
|
||||
("cloudProvider", env::var("MODAL_CLOUD_PROVIDER").ok()),
|
||||
("region", env::var("MODAL_REGION").ok()),
|
||||
|
|
@ -395,7 +407,9 @@ fn detect_docker() -> bool {
|
|||
false
|
||||
}
|
||||
|
||||
fn filter_metadata(pairs: impl IntoIterator<Item = (&'static str, Option<String>)>) -> HashMap<String, String> {
|
||||
fn filter_metadata(
|
||||
pairs: impl IntoIterator<Item = (&'static str, Option<String>)>,
|
||||
) -> HashMap<String, String> {
|
||||
let mut map = HashMap::new();
|
||||
for (key, value) in pairs {
|
||||
if let Some(value) = value {
|
||||
|
|
@ -407,7 +421,9 @@ fn filter_metadata(pairs: impl IntoIterator<Item = (&'static str, Option<String>
|
|||
map
|
||||
}
|
||||
|
||||
fn metadata_or_none(pairs: impl IntoIterator<Item = (&'static str, Option<String>)>) -> Option<HashMap<String, String>> {
|
||||
fn metadata_or_none(
|
||||
pairs: impl IntoIterator<Item = (&'static str, Option<String>)>,
|
||||
) -> Option<HashMap<String, String>> {
|
||||
let map = filter_metadata(pairs);
|
||||
if map.is_empty() {
|
||||
None
|
||||
|
|
|
|||
|
|
@ -37,7 +37,11 @@ fn serve_path(path: &str) -> Response {
|
|||
};
|
||||
|
||||
let trimmed = path.trim_start_matches('/');
|
||||
let target = if trimmed.is_empty() { "index.html" } else { trimmed };
|
||||
let target = if trimmed.is_empty() {
|
||||
"index.html"
|
||||
} else {
|
||||
trimmed
|
||||
};
|
||||
|
||||
if let Some(file) = dir.get_file(target) {
|
||||
return file_response(file);
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
#[path = "../common/mod.rs"]
|
||||
mod common;
|
||||
|
||||
use axum::http::Method;
|
||||
use common::*;
|
||||
use sandbox_agent_agent_management::testing::test_agents_from_env;
|
||||
use sandbox_agent_agent_management::agents::AgentId;
|
||||
use sandbox_agent_agent_management::testing::test_agents_from_env;
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
use std::time::{Duration, Instant};
|
||||
use axum::http::Method;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn agent_file_edit_flow() {
|
||||
|
|
@ -77,9 +77,7 @@ Do not change any other files. Reply only with DONE after editing.",
|
|||
let _ = send_status(
|
||||
&app.app,
|
||||
Method::POST,
|
||||
&format!(
|
||||
"/v1/sessions/{session_id}/permissions/{permission_id}/reply"
|
||||
),
|
||||
&format!("/v1/sessions/{session_id}/permissions/{permission_id}/reply"),
|
||||
Some(serde_json::json!({ "reply": "once" })),
|
||||
)
|
||||
.await;
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
#[path = "../common/mod.rs"]
|
||||
mod common;
|
||||
|
||||
use axum::http::Method;
|
||||
use common::*;
|
||||
use sandbox_agent_agent_management::testing::test_agents_from_env;
|
||||
use std::time::Duration;
|
||||
use axum::http::Method;
|
||||
use serde_json::json;
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn agent_permission_flow() {
|
||||
|
|
@ -41,14 +41,20 @@ async fn agent_permission_flow() {
|
|||
Some(json!({ "reply": "once" })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, axum::http::StatusCode::NO_CONTENT, "permission reply");
|
||||
assert_eq!(
|
||||
status,
|
||||
axum::http::StatusCode::NO_CONTENT,
|
||||
"permission reply"
|
||||
);
|
||||
|
||||
let resolved = poll_events_until(&app.app, &session_id, Duration::from_secs(120), |events| {
|
||||
events.iter().any(|event| {
|
||||
event.get("type").and_then(serde_json::Value::as_str) == Some("permission.resolved")
|
||||
let resolved =
|
||||
poll_events_until(&app.app, &session_id, Duration::from_secs(120), |events| {
|
||||
events.iter().any(|event| {
|
||||
event.get("type").and_then(serde_json::Value::as_str)
|
||||
== Some("permission.resolved")
|
||||
})
|
||||
})
|
||||
})
|
||||
.await;
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
resolved.iter().any(|event| {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
#[path = "../common/mod.rs"]
|
||||
mod common;
|
||||
|
||||
use axum::http::Method;
|
||||
use common::*;
|
||||
use sandbox_agent_agent_management::testing::test_agents_from_env;
|
||||
use std::time::Duration;
|
||||
use axum::http::Method;
|
||||
use serde_json::json;
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn agent_question_flow() {
|
||||
|
|
@ -44,12 +44,14 @@ async fn agent_question_flow() {
|
|||
.await;
|
||||
assert_eq!(status, axum::http::StatusCode::NO_CONTENT, "question reply");
|
||||
|
||||
let resolved = poll_events_until(&app.app, &session_id, Duration::from_secs(120), |events| {
|
||||
events.iter().any(|event| {
|
||||
event.get("type").and_then(serde_json::Value::as_str) == Some("question.resolved")
|
||||
let resolved =
|
||||
poll_events_until(&app.app, &session_id, Duration::from_secs(120), |events| {
|
||||
events.iter().any(|event| {
|
||||
event.get("type").and_then(serde_json::Value::as_str)
|
||||
== Some("question.resolved")
|
||||
})
|
||||
})
|
||||
})
|
||||
.await;
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
resolved.iter().any(|event| {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
#[path = "../common/mod.rs"]
|
||||
mod common;
|
||||
|
||||
use axum::http::Method;
|
||||
use common::*;
|
||||
use sandbox_agent_agent_management::testing::test_agents_from_env;
|
||||
use std::time::Duration;
|
||||
use axum::http::Method;
|
||||
use serde_json::json;
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn agent_termination() {
|
||||
|
|
@ -26,13 +26,20 @@ async fn agent_termination() {
|
|||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, axum::http::StatusCode::NO_CONTENT, "terminate session");
|
||||
assert_eq!(
|
||||
status,
|
||||
axum::http::StatusCode::NO_CONTENT,
|
||||
"terminate session"
|
||||
);
|
||||
|
||||
let events = poll_events_until(&app.app, &session_id, Duration::from_secs(30), |events| {
|
||||
has_event_type(events, "session.ended")
|
||||
})
|
||||
.await;
|
||||
assert!(has_event_type(&events, "session.ended"), "missing session.ended");
|
||||
assert!(
|
||||
has_event_type(&events, "session.ended"),
|
||||
"missing session.ended"
|
||||
);
|
||||
|
||||
let status = send_status(
|
||||
&app.app,
|
||||
|
|
@ -41,6 +48,9 @@ async fn agent_termination() {
|
|||
Some(json!({ "message": PROMPT })),
|
||||
)
|
||||
.await;
|
||||
assert!(!status.is_success(), "terminated session should reject messages");
|
||||
assert!(
|
||||
!status.is_success(),
|
||||
"terminated session should reject messages"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
#[path = "../common/mod.rs"]
|
||||
mod common;
|
||||
|
||||
use axum::http::Method;
|
||||
use common::*;
|
||||
use sandbox_agent_agent_management::testing::test_agents_from_env;
|
||||
use serde_json::Value;
|
||||
use std::time::{Duration, Instant};
|
||||
use axum::http::Method;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn agent_tool_flow() {
|
||||
|
|
@ -61,9 +61,7 @@ async fn agent_tool_flow() {
|
|||
let _ = send_status(
|
||||
&app.app,
|
||||
Method::POST,
|
||||
&format!(
|
||||
"/v1/sessions/{session_id}/permissions/{permission_id}/reply"
|
||||
),
|
||||
&format!("/v1/sessions/{session_id}/permissions/{permission_id}/reply"),
|
||||
Some(serde_json::json!({ "reply": "once" })),
|
||||
)
|
||||
.await;
|
||||
|
|
|
|||
|
|
@ -36,11 +36,19 @@ fn test_agents_install_version_spawn() -> Result<(), Box<dyn std::error::Error>>
|
|||
let env = build_env();
|
||||
assert!(!env.is_empty(), "expected credentials to be available");
|
||||
|
||||
let agents = [AgentId::Claude, AgentId::Codex, AgentId::Opencode, AgentId::Amp];
|
||||
let agents = [
|
||||
AgentId::Claude,
|
||||
AgentId::Codex,
|
||||
AgentId::Opencode,
|
||||
AgentId::Amp,
|
||||
];
|
||||
for agent in agents {
|
||||
let install = manager.install(agent, InstallOptions::default())?;
|
||||
assert!(install.path.exists(), "expected install for {agent}");
|
||||
assert!(manager.is_installed(agent), "expected is_installed for {agent}");
|
||||
assert!(
|
||||
manager.is_installed(agent),
|
||||
"expected is_installed for {agent}"
|
||||
);
|
||||
manager.install(
|
||||
agent,
|
||||
InstallOptions {
|
||||
|
|
@ -70,9 +78,15 @@ fn test_agents_install_version_spawn() -> Result<(), Box<dyn std::error::Error>>
|
|||
);
|
||||
let combined = format!("{}{}", result.stdout, result.stderr);
|
||||
let output = result.result.clone().unwrap_or(combined);
|
||||
assert!(output.contains("OK"), "expected OK for {agent}, got: {output}");
|
||||
assert!(
|
||||
output.contains("OK"),
|
||||
"expected OK for {agent}, got: {output}"
|
||||
);
|
||||
|
||||
if agent == AgentId::Claude || agent == AgentId::Opencode || (agent == AgentId::Amp && amp_configured()) {
|
||||
if agent == AgentId::Claude
|
||||
|| agent == AgentId::Opencode
|
||||
|| (agent == AgentId::Amp && amp_configured())
|
||||
{
|
||||
let mut resume = SpawnOptions::new(prompt_ok("OK2"));
|
||||
resume.env = env.clone();
|
||||
resume.session_id = result.session_id.clone();
|
||||
|
|
@ -84,12 +98,17 @@ fn test_agents_install_version_spawn() -> Result<(), Box<dyn std::error::Error>>
|
|||
);
|
||||
let combined = format!("{}{}", resumed.stdout, resumed.stderr);
|
||||
let output = resumed.result.clone().unwrap_or(combined);
|
||||
assert!(output.contains("OK2"), "expected OK2 for {agent}, got: {output}");
|
||||
assert!(
|
||||
output.contains("OK2"),
|
||||
"expected OK2 for {agent}, got: {output}"
|
||||
);
|
||||
} else if agent == AgentId::Codex {
|
||||
let mut resume = SpawnOptions::new(prompt_ok("OK2"));
|
||||
resume.env = env.clone();
|
||||
resume.session_id = result.session_id.clone();
|
||||
let err = manager.spawn(agent, resume).expect_err("expected resume error for codex");
|
||||
let err = manager
|
||||
.spawn(agent, resume)
|
||||
.expect_err("expected resume error for codex");
|
||||
assert!(matches!(err, AgentError::ResumeUnsupported { .. }));
|
||||
}
|
||||
|
||||
|
|
@ -105,7 +124,10 @@ fn test_agents_install_version_spawn() -> Result<(), Box<dyn std::error::Error>>
|
|||
);
|
||||
let combined = format!("{}{}", planned.stdout, planned.stderr);
|
||||
let output = planned.result.clone().unwrap_or(combined);
|
||||
assert!(output.contains("OK3"), "expected OK3 for {agent}, got: {output}");
|
||||
assert!(
|
||||
output.contains("OK3"),
|
||||
"expected OK3 for {agent}, got: {output}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,12 +9,7 @@ use serde_json::{json, Value};
|
|||
use tempfile::TempDir;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
use sandbox_agent::router::{
|
||||
build_router,
|
||||
AgentCapabilities,
|
||||
AgentListResponse,
|
||||
AuthConfig,
|
||||
};
|
||||
use sandbox_agent::router::{build_router, AgentCapabilities, AgentListResponse, AuthConfig};
|
||||
use sandbox_agent_agent_credentials::ExtractedCredentials;
|
||||
use sandbox_agent_agent_management::agents::{AgentId, AgentManager};
|
||||
|
||||
|
|
@ -32,8 +27,7 @@ pub struct TestApp {
|
|||
impl TestApp {
|
||||
pub fn new() -> Self {
|
||||
let install_dir = tempfile::tempdir().expect("create temp install dir");
|
||||
let manager = AgentManager::new(install_dir.path())
|
||||
.expect("create agent manager");
|
||||
let manager = AgentManager::new(install_dir.path()).expect("create agent manager");
|
||||
let state = sandbox_agent::router::AppState::new(AuthConfig::disabled(), manager);
|
||||
let app = build_router(state);
|
||||
Self {
|
||||
|
|
@ -59,7 +53,12 @@ impl Drop for EnvGuard {
|
|||
}
|
||||
|
||||
pub fn apply_credentials(creds: &ExtractedCredentials) -> EnvGuard {
|
||||
let keys = ["ANTHROPIC_API_KEY", "CLAUDE_API_KEY", "OPENAI_API_KEY", "CODEX_API_KEY"];
|
||||
let keys = [
|
||||
"ANTHROPIC_API_KEY",
|
||||
"CLAUDE_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
"CODEX_API_KEY",
|
||||
];
|
||||
let mut saved = HashMap::new();
|
||||
for key in keys {
|
||||
saved.insert(key.to_string(), std::env::var(key).ok());
|
||||
|
|
@ -100,13 +99,11 @@ pub async fn send_json(
|
|||
.method(method)
|
||||
.uri(path)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(body.map(|value| value.to_string()).unwrap_or_default()))
|
||||
.body(Body::from(
|
||||
body.map(|value| value.to_string()).unwrap_or_default(),
|
||||
))
|
||||
.expect("request");
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(request)
|
||||
.await
|
||||
.expect("response");
|
||||
let response = app.clone().oneshot(request).await.expect("response");
|
||||
let status = response.status();
|
||||
let bytes = response
|
||||
.into_body()
|
||||
|
|
@ -140,15 +137,15 @@ pub async fn install_agent(app: &Router, agent: AgentId) {
|
|||
Some(json!({})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NO_CONTENT, "install agent {}", agent.as_str());
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::NO_CONTENT,
|
||||
"install agent {}",
|
||||
agent.as_str()
|
||||
);
|
||||
}
|
||||
|
||||
pub async fn create_session(
|
||||
app: &Router,
|
||||
agent: AgentId,
|
||||
session_id: &str,
|
||||
permission_mode: &str,
|
||||
) {
|
||||
pub async fn create_session(app: &Router, agent: AgentId, session_id: &str, permission_mode: &str) {
|
||||
let status = send_status(
|
||||
app,
|
||||
Method::POST,
|
||||
|
|
|
|||
|
|
@ -28,11 +28,7 @@ async fn register_and_unregister_sessions() {
|
|||
.register_session(AgentId::Codex, "sess-1", Some("thread-1"))
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
harness
|
||||
.has_session_mapping(AgentId::Codex, "sess-1")
|
||||
.await
|
||||
);
|
||||
assert!(harness.has_session_mapping(AgentId::Codex, "sess-1").await);
|
||||
assert_eq!(
|
||||
harness
|
||||
.native_mapping(AgentId::Codex, "thread-1")
|
||||
|
|
@ -45,17 +41,11 @@ async fn register_and_unregister_sessions() {
|
|||
.unregister_session(AgentId::Codex, "sess-1", Some("thread-1"))
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
!harness
|
||||
.has_session_mapping(AgentId::Codex, "sess-1")
|
||||
.await
|
||||
);
|
||||
assert!(
|
||||
harness
|
||||
.native_mapping(AgentId::Codex, "thread-1")
|
||||
.await
|
||||
.is_none()
|
||||
);
|
||||
assert!(!harness.has_session_mapping(AgentId::Codex, "sess-1").await);
|
||||
assert!(harness
|
||||
.native_mapping(AgentId::Codex, "thread-1")
|
||||
.await
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -92,9 +82,7 @@ async fn handle_process_exit_marks_error_and_ends_sessions() {
|
|||
harness
|
||||
.register_session(AgentId::Codex, "sess-1", Some("thread-1"))
|
||||
.await;
|
||||
harness
|
||||
.insert_stdio_server(AgentId::Codex, None, 1)
|
||||
.await;
|
||||
harness.insert_stdio_server(AgentId::Codex, None, 1).await;
|
||||
|
||||
harness
|
||||
.handle_process_exit(AgentId::Codex, 1, exit_status(7))
|
||||
|
|
@ -104,13 +92,11 @@ async fn handle_process_exit_marks_error_and_ends_sessions() {
|
|||
harness.server_status(AgentId::Codex).await,
|
||||
Some(sandbox_agent::router::ServerStatus::Error)
|
||||
));
|
||||
assert!(
|
||||
harness
|
||||
.server_last_error(AgentId::Codex)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.contains("exited")
|
||||
);
|
||||
assert!(harness
|
||||
.server_last_error(AgentId::Codex)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.contains("exited"));
|
||||
assert!(harness.session_ended("sess-1").await);
|
||||
assert!(matches!(
|
||||
harness.session_end_reason("sess-1").await,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
mod session_lifecycle;
|
||||
mod multi_turn;
|
||||
mod permissions;
|
||||
mod questions;
|
||||
mod reasoning;
|
||||
mod session_lifecycle;
|
||||
mod status;
|
||||
|
|
|
|||
|
|
@ -81,8 +81,13 @@ async fn multi_turn_snapshots() {
|
|||
install_agent(&app.app, config.agent).await;
|
||||
|
||||
let session_id = format!("multi-turn-{}", config.agent.as_str());
|
||||
create_session(&app.app, config.agent, &session_id, test_permission_mode(config.agent))
|
||||
.await;
|
||||
create_session(
|
||||
&app.app,
|
||||
config.agent,
|
||||
&session_id,
|
||||
test_permission_mode(config.agent),
|
||||
)
|
||||
.await;
|
||||
|
||||
send_message_with_text(&app.app, &session_id, FIRST_PROMPT).await;
|
||||
let (first_events, offset) =
|
||||
|
|
@ -100,13 +105,8 @@ async fn multi_turn_snapshots() {
|
|||
);
|
||||
|
||||
send_message_with_text(&app.app, &session_id, SECOND_PROMPT).await;
|
||||
let (second_events, _offset) = poll_events_until_from(
|
||||
&app.app,
|
||||
&session_id,
|
||||
offset,
|
||||
Duration::from_secs(120),
|
||||
)
|
||||
.await;
|
||||
let (second_events, _offset) =
|
||||
poll_events_until_from(&app.app, &session_id, offset, Duration::from_secs(120)).await;
|
||||
let second_events = truncate_after_first_stop(&second_events);
|
||||
assert!(
|
||||
!second_events.is_empty(),
|
||||
|
|
|
|||
|
|
@ -55,9 +55,7 @@ async fn permission_flow_snapshots() {
|
|||
let status = send_status(
|
||||
&app.app,
|
||||
Method::POST,
|
||||
&format!(
|
||||
"/v1/sessions/{permission_session}/permissions/{permission_id}/reply"
|
||||
),
|
||||
&format!("/v1/sessions/{permission_session}/permissions/{permission_id}/reply"),
|
||||
Some(json!({ "reply": "once" })),
|
||||
)
|
||||
.await;
|
||||
|
|
@ -67,9 +65,7 @@ async fn permission_flow_snapshots() {
|
|||
let (status, payload) = send_json(
|
||||
&app.app,
|
||||
Method::POST,
|
||||
&format!(
|
||||
"/v1/sessions/{permission_session}/permissions/missing-permission/reply"
|
||||
),
|
||||
&format!("/v1/sessions/{permission_session}/permissions/missing-permission/reply"),
|
||||
Some(json!({ "reply": "once" })),
|
||||
)
|
||||
.await;
|
||||
|
|
|
|||
|
|
@ -55,9 +55,7 @@ async fn question_flow_snapshots() {
|
|||
let status = send_status(
|
||||
&app.app,
|
||||
Method::POST,
|
||||
&format!(
|
||||
"/v1/sessions/{question_reply_session}/questions/{question_id}/reply"
|
||||
),
|
||||
&format!("/v1/sessions/{question_reply_session}/questions/{question_id}/reply"),
|
||||
Some(json!({ "answers": answers })),
|
||||
)
|
||||
.await;
|
||||
|
|
@ -67,9 +65,7 @@ async fn question_flow_snapshots() {
|
|||
let (status, payload) = send_json(
|
||||
&app.app,
|
||||
Method::POST,
|
||||
&format!(
|
||||
"/v1/sessions/{question_reply_session}/questions/missing-question/reply"
|
||||
),
|
||||
&format!("/v1/sessions/{question_reply_session}/questions/missing-question/reply"),
|
||||
Some(json!({ "answers": [] })),
|
||||
)
|
||||
.await;
|
||||
|
|
@ -92,7 +88,11 @@ async fn question_flow_snapshots() {
|
|||
Some(json!({ "message": QUESTION_PROMPT })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NO_CONTENT, "send question prompt reject");
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::NO_CONTENT,
|
||||
"send question prompt reject"
|
||||
);
|
||||
|
||||
let reject_events = poll_events_until_match(
|
||||
&app.app,
|
||||
|
|
@ -108,9 +108,7 @@ async fn question_flow_snapshots() {
|
|||
let status = send_status(
|
||||
&app.app,
|
||||
Method::POST,
|
||||
&format!(
|
||||
"/v1/sessions/{question_reject_session}/questions/{question_id}/reject"
|
||||
),
|
||||
&format!("/v1/sessions/{question_reject_session}/questions/{question_id}/reject"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
|
@ -126,7 +124,10 @@ async fn question_flow_snapshots() {
|
|||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(!status.is_success(), "missing question id reject should error");
|
||||
assert!(
|
||||
!status.is_success(),
|
||||
"missing question id reject should error"
|
||||
);
|
||||
assert_session_snapshot(
|
||||
"question_reject_missing",
|
||||
json!({
|
||||
|
|
|
|||
|
|
@ -23,8 +23,13 @@ async fn reasoning_events_present() {
|
|||
install_agent(&app.app, config.agent).await;
|
||||
|
||||
let session_id = format!("reasoning-{}", config.agent.as_str());
|
||||
create_session(&app.app, config.agent, &session_id, test_permission_mode(config.agent))
|
||||
.await;
|
||||
create_session(
|
||||
&app.app,
|
||||
config.agent,
|
||||
&session_id,
|
||||
test_permission_mode(config.agent),
|
||||
)
|
||||
.await;
|
||||
let status = send_status(
|
||||
&app.app,
|
||||
Method::POST,
|
||||
|
|
@ -34,13 +39,11 @@ async fn reasoning_events_present() {
|
|||
.await;
|
||||
assert_eq!(status, StatusCode::NO_CONTENT, "send reasoning prompt");
|
||||
|
||||
let events = poll_events_until_match(
|
||||
&app.app,
|
||||
&session_id,
|
||||
Duration::from_secs(120),
|
||||
|events| events_have_content_type(events, "reasoning") || events.iter().any(is_error_event),
|
||||
)
|
||||
.await;
|
||||
let events =
|
||||
poll_events_until_match(&app.app, &session_id, Duration::from_secs(120), |events| {
|
||||
events_have_content_type(events, "reasoning") || events.iter().any(is_error_event)
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
events_have_content_type(&events, "reasoning"),
|
||||
"expected reasoning content for {}",
|
||||
|
|
|
|||
|
|
@ -28,8 +28,13 @@ async fn status_events_present() {
|
|||
install_agent(&app.app, config.agent).await;
|
||||
|
||||
let session_id = format!("status-{}", config.agent.as_str());
|
||||
create_session(&app.app, config.agent, &session_id, test_permission_mode(config.agent))
|
||||
.await;
|
||||
create_session(
|
||||
&app.app,
|
||||
config.agent,
|
||||
&session_id,
|
||||
test_permission_mode(config.agent),
|
||||
)
|
||||
.await;
|
||||
let status = send_status(
|
||||
&app.app,
|
||||
Method::POST,
|
||||
|
|
@ -39,13 +44,11 @@ async fn status_events_present() {
|
|||
.await;
|
||||
assert_eq!(status, StatusCode::NO_CONTENT, "send status prompt");
|
||||
|
||||
let events = poll_events_until_match(
|
||||
&app.app,
|
||||
&session_id,
|
||||
Duration::from_secs(120),
|
||||
|events| events_have_status(events) || events.iter().any(is_error_event),
|
||||
)
|
||||
.await;
|
||||
let events =
|
||||
poll_events_until_match(&app.app, &session_id, Duration::from_secs(120), |events| {
|
||||
events_have_status(events) || events.iter().any(is_error_event)
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
events_have_status(&events),
|
||||
"expected status events for {}",
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use http_body_util::BodyExt;
|
||||
use sandbox_agent_agent_management::agents::AgentManager;
|
||||
use sandbox_agent::router::{build_router, AppState, AuthConfig};
|
||||
use sandbox_agent::ui;
|
||||
use sandbox_agent_agent_management::agents::AgentManager;
|
||||
use tempfile::TempDir;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
|
|
@ -22,10 +22,7 @@ async fn serves_inspector_ui() {
|
|||
.uri("/ui")
|
||||
.body(Body::empty())
|
||||
.expect("build request");
|
||||
let response = app
|
||||
.oneshot(request)
|
||||
.await
|
||||
.expect("request handled");
|
||||
let response = app.oneshot(request).await.expect("request handled");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue