mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 10:05:18 +00:00
feat: add opencode compatibility layer (#68)
This commit is contained in:
parent
cc5a9e0d73
commit
ef3e811c94
32 changed files with 18163 additions and 310 deletions
|
|
@ -25,6 +25,7 @@ futures.workspace = true
|
|||
reqwest.workspace = true
|
||||
dirs.workspace = true
|
||||
time.workspace = true
|
||||
chrono.workspace = true
|
||||
tokio.workspace = true
|
||||
tokio-stream.workspace = true
|
||||
tower-http.workspace = true
|
||||
|
|
@ -34,11 +35,15 @@ tracing.workspace = true
|
|||
tracing-logfmt.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
include_dir.workspace = true
|
||||
base64.workspace = true
|
||||
tempfile = { workspace = true, optional = true }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.52", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Console"] }
|
||||
|
||||
[dev-dependencies]
|
||||
http-body-util.workspace = true
|
||||
insta.workspace = true
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
mod agent_server_logs;
|
||||
pub mod credentials;
|
||||
pub mod opencode_compat;
|
||||
pub mod router;
|
||||
pub mod server_logs;
|
||||
pub mod telemetry;
|
||||
pub mod ui;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command as ProcessCommand, Stdio};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use clap::{Args, Parser, Subcommand};
|
||||
|
||||
|
|
@ -20,6 +22,7 @@ use sandbox_agent::router::{
|
|||
AgentListResponse, AgentModesResponse, CreateSessionResponse, EventsResponse,
|
||||
SessionListResponse,
|
||||
};
|
||||
use sandbox_agent::server_logs::ServerLogs;
|
||||
use sandbox_agent::telemetry;
|
||||
use sandbox_agent::ui;
|
||||
use sandbox_agent_agent_management::agents::{AgentId, AgentManager, InstallOptions};
|
||||
|
|
@ -28,7 +31,7 @@ use sandbox_agent_agent_management::credentials::{
|
|||
ProviderCredentials,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use serde_json::{json, Value};
|
||||
use thiserror::Error;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
|
||||
|
|
@ -36,6 +39,7 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilte
|
|||
const API_PREFIX: &str = "/v1";
|
||||
const DEFAULT_HOST: &str = "127.0.0.1";
|
||||
const DEFAULT_PORT: u16 = 2468;
|
||||
const LOGS_RETENTION: Duration = Duration::from_secs(7 * 24 * 60 * 60);
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "sandbox-agent", bin_name = "sandbox-agent")]
|
||||
|
|
@ -58,6 +62,8 @@ enum Command {
|
|||
Server(ServerArgs),
|
||||
/// Call the HTTP API without writing client code.
|
||||
Api(ApiArgs),
|
||||
/// EXPERIMENTAL: Start a sandbox-agent server and attach an OpenCode session.
|
||||
Opencode(OpencodeArgs),
|
||||
/// Install or reinstall an agent without running the server.
|
||||
InstallAgent(InstallAgentArgs),
|
||||
/// Inspect locally discovered credentials.
|
||||
|
|
@ -94,6 +100,21 @@ struct ApiArgs {
|
|||
command: ApiCommand,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct OpencodeArgs {
|
||||
#[arg(long, short = 'H', default_value = DEFAULT_HOST)]
|
||||
host: String,
|
||||
|
||||
#[arg(long, short = 'p', default_value_t = DEFAULT_PORT)]
|
||||
port: u16,
|
||||
|
||||
#[arg(long)]
|
||||
session_title: Option<String>,
|
||||
|
||||
#[arg(long)]
|
||||
opencode_bin: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct CredentialsArgs {
|
||||
#[command(subcommand)]
|
||||
|
|
@ -349,8 +370,11 @@ enum CliError {
|
|||
}
|
||||
|
||||
fn main() {
|
||||
init_logging();
|
||||
let cli = Cli::parse();
|
||||
if let Err(err) = init_logging(&cli) {
|
||||
eprintln!("failed to init logging: {err}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let result = match &cli.command {
|
||||
Command::Server(args) => run_server(&cli, args),
|
||||
|
|
@ -363,7 +387,11 @@ fn main() {
|
|||
}
|
||||
}
|
||||
|
||||
fn init_logging() {
|
||||
fn init_logging(cli: &Cli) -> Result<(), CliError> {
|
||||
if matches!(cli.command, Command::Server(_)) {
|
||||
maybe_redirect_server_logs();
|
||||
}
|
||||
|
||||
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
|
||||
tracing_subscriber::registry()
|
||||
.with(filter)
|
||||
|
|
@ -373,6 +401,7 @@ fn init_logging() {
|
|||
.with_writer(std::io::stderr),
|
||||
)
|
||||
.init();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_server(cli: &Cli, server: &ServerArgs) -> Result<(), CliError> {
|
||||
|
|
@ -434,12 +463,33 @@ fn default_install_dir() -> PathBuf {
|
|||
.unwrap_or_else(|| PathBuf::from(".").join(".sandbox-agent").join("bin"))
|
||||
}
|
||||
|
||||
fn default_server_log_dir() -> PathBuf {
|
||||
if let Ok(dir) = std::env::var("SANDBOX_AGENT_LOG_DIR") {
|
||||
return PathBuf::from(dir);
|
||||
}
|
||||
dirs::data_dir()
|
||||
.map(|dir| dir.join("sandbox-agent").join("logs"))
|
||||
.unwrap_or_else(|| PathBuf::from(".").join(".sandbox-agent").join("logs"))
|
||||
}
|
||||
|
||||
fn maybe_redirect_server_logs() {
|
||||
if std::env::var("SANDBOX_AGENT_LOG_STDOUT").is_ok() {
|
||||
return;
|
||||
}
|
||||
|
||||
let log_dir = default_server_log_dir();
|
||||
if let Err(err) = ServerLogs::new(log_dir, LOGS_RETENTION).start_sync() {
|
||||
eprintln!("failed to redirect logs: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
fn run_client(command: &Command, cli: &Cli) -> Result<(), CliError> {
|
||||
match command {
|
||||
Command::Server(_) => Err(CliError::Server(
|
||||
"server subcommand must be invoked as `sandbox-agent server`".to_string(),
|
||||
)),
|
||||
Command::Api(subcommand) => run_api(&subcommand.command, cli),
|
||||
Command::Opencode(args) => run_opencode(cli, args),
|
||||
Command::InstallAgent(args) => install_agent_local(args),
|
||||
Command::Credentials(subcommand) => run_credentials(&subcommand.command),
|
||||
}
|
||||
|
|
@ -452,6 +502,53 @@ fn run_api(command: &ApiCommand, cli: &Cli) -> Result<(), CliError> {
|
|||
}
|
||||
}
|
||||
|
||||
fn run_opencode(cli: &Cli, args: &OpencodeArgs) -> Result<(), CliError> {
|
||||
write_stderr_line("experimental: opencode subcommand may change without notice")?;
|
||||
|
||||
let token = if cli.no_token {
|
||||
None
|
||||
} else {
|
||||
Some(cli.token.clone().ok_or(CliError::MissingToken)?)
|
||||
};
|
||||
|
||||
let mut server_child = spawn_sandbox_agent_server(cli, args, token.as_deref())?;
|
||||
let base_url = format!("http://{}:{}", args.host, args.port);
|
||||
wait_for_health(&mut server_child, &base_url, token.as_deref())?;
|
||||
|
||||
let session_id = create_opencode_session(&base_url, token.as_deref(), args.session_title.as_deref())?;
|
||||
write_stdout_line(&format!("OpenCode session: {session_id}"))?;
|
||||
|
||||
let attach_url = format!("{base_url}/opencode");
|
||||
let opencode_bin = resolve_opencode_bin(args.opencode_bin.as_ref());
|
||||
let mut opencode_cmd = ProcessCommand::new(opencode_bin);
|
||||
opencode_cmd
|
||||
.arg("attach")
|
||||
.arg(&attach_url)
|
||||
.arg("--session")
|
||||
.arg(&session_id)
|
||||
.stdin(Stdio::inherit())
|
||||
.stdout(Stdio::inherit())
|
||||
.stderr(Stdio::inherit());
|
||||
if let Some(token) = token.as_deref() {
|
||||
opencode_cmd.arg("--password").arg(token);
|
||||
}
|
||||
|
||||
let status = opencode_cmd.status().map_err(|err| {
|
||||
terminate_child(&mut server_child);
|
||||
CliError::Server(format!("failed to start opencode: {err}"))
|
||||
})?;
|
||||
|
||||
terminate_child(&mut server_child);
|
||||
|
||||
if !status.success() {
|
||||
return Err(CliError::Server(format!(
|
||||
"opencode exited with status {status}"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_agents(command: &AgentsCommand, cli: &Cli) -> Result<(), CliError> {
|
||||
match command {
|
||||
AgentsCommand::List(args) => {
|
||||
|
|
@ -607,6 +704,113 @@ fn run_sessions(command: &SessionsCommand, cli: &Cli) -> Result<(), CliError> {
|
|||
}
|
||||
}
|
||||
|
||||
fn spawn_sandbox_agent_server(
|
||||
cli: &Cli,
|
||||
args: &OpencodeArgs,
|
||||
token: Option<&str>,
|
||||
) -> Result<Child, CliError> {
|
||||
let exe = std::env::current_exe()?;
|
||||
let mut cmd = ProcessCommand::new(exe);
|
||||
cmd.arg("server")
|
||||
.arg("--host")
|
||||
.arg(&args.host)
|
||||
.arg("--port")
|
||||
.arg(args.port.to_string())
|
||||
.stdin(Stdio::inherit())
|
||||
.stdout(Stdio::inherit())
|
||||
.stderr(Stdio::inherit());
|
||||
|
||||
if cli.no_token {
|
||||
cmd.arg("--no-token");
|
||||
} else if let Some(token) = token {
|
||||
cmd.arg("--token").arg(token);
|
||||
}
|
||||
|
||||
cmd.spawn().map_err(CliError::from)
|
||||
}
|
||||
|
||||
fn wait_for_health(
|
||||
server_child: &mut Child,
|
||||
base_url: &str,
|
||||
token: Option<&str>,
|
||||
) -> Result<(), CliError> {
|
||||
let client = HttpClient::builder().build()?;
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
|
||||
while Instant::now() < deadline {
|
||||
if let Some(status) = server_child.try_wait()? {
|
||||
return Err(CliError::Server(format!(
|
||||
"sandbox-agent exited before becoming healthy ({status})"
|
||||
)));
|
||||
}
|
||||
|
||||
let url = format!("{base_url}/v1/health");
|
||||
let mut request = client.get(&url);
|
||||
if let Some(token) = token {
|
||||
request = request.bearer_auth(token);
|
||||
}
|
||||
match request.send() {
|
||||
Ok(response) if response.status().is_success() => return Ok(()),
|
||||
_ => {
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(CliError::Server(
|
||||
"timed out waiting for sandbox-agent health".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn create_opencode_session(
|
||||
base_url: &str,
|
||||
token: Option<&str>,
|
||||
title: Option<&str>,
|
||||
) -> Result<String, CliError> {
|
||||
let client = HttpClient::builder().build()?;
|
||||
let url = format!("{base_url}/opencode/session");
|
||||
let body = if let Some(title) = title {
|
||||
json!({ "title": title })
|
||||
} else {
|
||||
json!({})
|
||||
};
|
||||
let mut request = client.post(&url).json(&body);
|
||||
if let Ok(directory) = std::env::current_dir() {
|
||||
request = request.header("x-opencode-directory", directory.to_string_lossy().to_string());
|
||||
}
|
||||
if let Some(token) = token {
|
||||
request = request.bearer_auth(token);
|
||||
}
|
||||
let response = request.send()?;
|
||||
let status = response.status();
|
||||
let text = response.text()?;
|
||||
if !status.is_success() {
|
||||
print_error_body(&text)?;
|
||||
return Err(CliError::HttpStatus(status));
|
||||
}
|
||||
let body: Value = serde_json::from_str(&text)?;
|
||||
let session_id = body
|
||||
.get("id")
|
||||
.and_then(|value| value.as_str())
|
||||
.ok_or_else(|| CliError::Server("opencode session missing id".to_string()))?;
|
||||
Ok(session_id.to_string())
|
||||
}
|
||||
|
||||
fn resolve_opencode_bin(explicit: Option<&PathBuf>) -> PathBuf {
|
||||
if let Some(path) = explicit {
|
||||
return path.clone();
|
||||
}
|
||||
if let Ok(path) = std::env::var("OPENCODE_BIN") {
|
||||
return PathBuf::from(path);
|
||||
}
|
||||
PathBuf::from("opencode")
|
||||
}
|
||||
|
||||
fn terminate_child(child: &mut Child) {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
fn run_credentials(command: &CredentialsCommand) -> Result<(), CliError> {
|
||||
match command {
|
||||
CredentialsCommand::Extract(args) => {
|
||||
|
|
|
|||
4274
server/packages/sandbox-agent/src/opencode_compat.rs
Normal file
4274
server/packages/sandbox-agent/src/opencode_compat.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -34,9 +34,12 @@ use tokio::sync::{broadcast, mpsc, oneshot, Mutex};
|
|||
use tokio::time::sleep;
|
||||
use tokio_stream::wrappers::BroadcastStream;
|
||||
use tower_http::trace::TraceLayer;
|
||||
use base64::Engine;
|
||||
use tracing::Span;
|
||||
use utoipa::{Modify, OpenApi, ToSchema};
|
||||
|
||||
use crate::agent_server_logs::AgentServerLogs;
|
||||
use crate::opencode_compat::{build_opencode_router, OpenCodeAppState};
|
||||
use crate::ui;
|
||||
use sandbox_agent_agent_management::agents::{
|
||||
AgentError as ManagerError, AgentId, AgentManager, InstallOptions, SpawnOptions, StreamingSpawn,
|
||||
|
|
@ -68,6 +71,10 @@ impl AppState {
|
|||
session_manager,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn session_manager(&self) -> Arc<SessionManager> {
|
||||
self.session_manager.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
|
@ -126,16 +133,78 @@ pub fn build_router_with_state(shared: Arc<AppState>) -> (Router, Arc<AppState>)
|
|||
));
|
||||
}
|
||||
|
||||
let opencode_state = OpenCodeAppState::new(shared.clone());
|
||||
let mut opencode_router = build_opencode_router(opencode_state.clone());
|
||||
let mut opencode_root_router = build_opencode_router(opencode_state);
|
||||
if shared.auth.token.is_some() {
|
||||
opencode_router = opencode_router.layer(axum::middleware::from_fn_with_state(
|
||||
shared.clone(),
|
||||
require_token,
|
||||
));
|
||||
opencode_root_router = opencode_root_router.layer(axum::middleware::from_fn_with_state(
|
||||
shared.clone(),
|
||||
require_token,
|
||||
));
|
||||
}
|
||||
|
||||
let mut router = Router::new()
|
||||
.route("/", get(get_root))
|
||||
.nest("/v1", v1_router)
|
||||
.nest("/opencode", opencode_router)
|
||||
.merge(opencode_root_router)
|
||||
.fallback(not_found);
|
||||
|
||||
if ui::is_enabled() {
|
||||
router = router.merge(ui::router());
|
||||
}
|
||||
|
||||
(router.layer(TraceLayer::new_for_http()), shared)
|
||||
let http_logging = match std::env::var("SANDBOX_AGENT_LOG_HTTP") {
|
||||
Ok(value) if value == "0" || value.eq_ignore_ascii_case("false") => false,
|
||||
_ => true,
|
||||
};
|
||||
if http_logging {
|
||||
let include_headers = std::env::var("SANDBOX_AGENT_LOG_HTTP_HEADERS").is_ok();
|
||||
let trace_layer = TraceLayer::new_for_http()
|
||||
.make_span_with(move |req: &Request<_>| {
|
||||
if include_headers {
|
||||
let mut headers = Vec::new();
|
||||
for (name, value) in req.headers().iter() {
|
||||
let name_str = name.as_str();
|
||||
let display_value = if name_str.eq_ignore_ascii_case("authorization") {
|
||||
"<redacted>".to_string()
|
||||
} else {
|
||||
value.to_str().unwrap_or("<binary>").to_string()
|
||||
};
|
||||
headers.push((name_str.to_string(), display_value));
|
||||
}
|
||||
tracing::info_span!(
|
||||
"http.request",
|
||||
method = %req.method(),
|
||||
uri = %req.uri(),
|
||||
headers = ?headers
|
||||
)
|
||||
} else {
|
||||
tracing::info_span!(
|
||||
"http.request",
|
||||
method = %req.method(),
|
||||
uri = %req.uri()
|
||||
)
|
||||
}
|
||||
})
|
||||
.on_request(|_req: &Request<_>, span: &Span| {
|
||||
tracing::info!(parent: span, "request");
|
||||
})
|
||||
.on_response(|res: &Response<_>, latency: Duration, span: &Span| {
|
||||
tracing::info!(
|
||||
parent: span,
|
||||
status = %res.status(),
|
||||
latency_ms = latency.as_millis()
|
||||
);
|
||||
});
|
||||
router = router.layer(trace_layer);
|
||||
}
|
||||
|
||||
(router, shared)
|
||||
}
|
||||
|
||||
pub async fn shutdown_servers(state: &Arc<AppState>) {
|
||||
|
|
@ -744,7 +813,7 @@ struct AgentServerManager {
|
|||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SessionManager {
|
||||
pub(crate) struct SessionManager {
|
||||
agent_manager: Arc<AgentManager>,
|
||||
sessions: Mutex<Vec<SessionState>>,
|
||||
server_manager: Arc<AgentServerManager>,
|
||||
|
|
@ -847,9 +916,25 @@ impl CodexServer {
|
|||
}
|
||||
}
|
||||
|
||||
struct SessionSubscription {
|
||||
initial_events: Vec<UniversalEvent>,
|
||||
receiver: broadcast::Receiver<UniversalEvent>,
|
||||
pub(crate) struct SessionSubscription {
|
||||
pub(crate) initial_events: Vec<UniversalEvent>,
|
||||
pub(crate) receiver: broadcast::Receiver<UniversalEvent>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct PendingPermissionInfo {
|
||||
pub session_id: String,
|
||||
pub permission_id: String,
|
||||
pub action: String,
|
||||
pub metadata: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct PendingQuestionInfo {
|
||||
pub session_id: String,
|
||||
pub question_id: String,
|
||||
pub prompt: String,
|
||||
pub options: Vec<String>,
|
||||
}
|
||||
|
||||
impl ManagedServer {
|
||||
|
|
@ -1477,7 +1562,7 @@ impl SessionManager {
|
|||
logs.read_stderr()
|
||||
}
|
||||
|
||||
async fn create_session(
|
||||
pub(crate) async fn create_session(
|
||||
self: &Arc<Self>,
|
||||
session_id: String,
|
||||
request: CreateSessionRequest,
|
||||
|
|
@ -1604,7 +1689,7 @@ impl SessionManager {
|
|||
}
|
||||
}
|
||||
|
||||
async fn send_message(
|
||||
pub(crate) async fn send_message(
|
||||
self: &Arc<Self>,
|
||||
session_id: String,
|
||||
message: String,
|
||||
|
|
@ -1819,7 +1904,7 @@ impl SessionManager {
|
|||
.collect()
|
||||
}
|
||||
|
||||
async fn subscribe(
|
||||
pub(crate) async fn subscribe(
|
||||
&self,
|
||||
session_id: &str,
|
||||
offset: u64,
|
||||
|
|
@ -1843,6 +1928,38 @@ impl SessionManager {
|
|||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn list_pending_permissions(&self) -> Vec<PendingPermissionInfo> {
|
||||
let sessions = self.sessions.lock().await;
|
||||
let mut items = Vec::new();
|
||||
for session in sessions.iter() {
|
||||
for (permission_id, pending) in session.pending_permissions.iter() {
|
||||
items.push(PendingPermissionInfo {
|
||||
session_id: session.session_id.clone(),
|
||||
permission_id: permission_id.clone(),
|
||||
action: pending.action.clone(),
|
||||
metadata: pending.metadata.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
pub(crate) async fn list_pending_questions(&self) -> Vec<PendingQuestionInfo> {
|
||||
let sessions = self.sessions.lock().await;
|
||||
let mut items = Vec::new();
|
||||
for session in sessions.iter() {
|
||||
for (question_id, pending) in session.pending_questions.iter() {
|
||||
items.push(PendingQuestionInfo {
|
||||
session_id: session.session_id.clone(),
|
||||
question_id: question_id.clone(),
|
||||
prompt: pending.prompt.clone(),
|
||||
options: pending.options.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
async fn subscribe_for_turn(
|
||||
&self,
|
||||
session_id: &str,
|
||||
|
|
@ -1871,7 +1988,7 @@ impl SessionManager {
|
|||
Ok((SessionSnapshot::from(session), subscription))
|
||||
}
|
||||
|
||||
async fn reply_question(
|
||||
pub(crate) async fn reply_question(
|
||||
&self,
|
||||
session_id: &str,
|
||||
question_id: &str,
|
||||
|
|
@ -1949,7 +2066,7 @@ impl SessionManager {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn reject_question(
|
||||
pub(crate) async fn reject_question(
|
||||
&self,
|
||||
session_id: &str,
|
||||
question_id: &str,
|
||||
|
|
@ -2028,7 +2145,7 @@ impl SessionManager {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn reply_permission(
|
||||
pub(crate) async fn reply_permission(
|
||||
self: &Arc<Self>,
|
||||
session_id: &str,
|
||||
permission_id: &str,
|
||||
|
|
@ -3291,11 +3408,35 @@ fn extract_token(headers: &HeaderMap) -> Option<String> {
|
|||
if let Some(value) = headers.get(axum::http::header::AUTHORIZATION) {
|
||||
if let Ok(value) = value.to_str() {
|
||||
let value = value.trim();
|
||||
if let Some(stripped) = value.strip_prefix("Bearer ") {
|
||||
return Some(stripped.to_string());
|
||||
}
|
||||
if let Some(stripped) = value.strip_prefix("Token ") {
|
||||
return Some(stripped.to_string());
|
||||
if let Some((scheme, rest)) = value.split_once(' ') {
|
||||
let scheme_lower = scheme.to_ascii_lowercase();
|
||||
let rest = rest.trim();
|
||||
match scheme_lower.as_str() {
|
||||
"bearer" | "token" => {
|
||||
return Some(rest.to_string());
|
||||
}
|
||||
"basic" => {
|
||||
let engines = [
|
||||
base64::engine::general_purpose::STANDARD,
|
||||
base64::engine::general_purpose::STANDARD_NO_PAD,
|
||||
base64::engine::general_purpose::URL_SAFE,
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD,
|
||||
];
|
||||
for engine in engines {
|
||||
if let Ok(decoded) = engine.decode(rest) {
|
||||
if let Ok(decoded_str) = String::from_utf8(decoded) {
|
||||
if let Some((_, password)) = decoded_str.split_once(':') {
|
||||
return Some(password.to_string());
|
||||
}
|
||||
if !decoded_str.is_empty() {
|
||||
return Some(decoded_str);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
9
server/packages/sandbox-agent/src/server_logs/mod.rs
Normal file
9
server/packages/sandbox-agent/src/server_logs/mod.rs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
#[cfg(unix)]
|
||||
mod unix;
|
||||
#[cfg(windows)]
|
||||
mod windows;
|
||||
|
||||
#[cfg(unix)]
|
||||
pub use unix::ServerLogs;
|
||||
#[cfg(windows)]
|
||||
pub use windows::ServerLogs;
|
||||
103
server/packages/sandbox-agent/src/server_logs/unix.rs
Normal file
103
server/packages/sandbox-agent/src/server_logs/unix.rs
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
use std::os::unix::io::AsRawFd;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use chrono::{Datelike, Duration, TimeDelta, TimeZone, Utc};
|
||||
|
||||
pub struct ServerLogs {
|
||||
path: PathBuf,
|
||||
retention: Duration,
|
||||
|
||||
last_rotation: chrono::DateTime<Utc>,
|
||||
next_rotation: chrono::DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl ServerLogs {
|
||||
pub fn new(path: PathBuf, retention: std::time::Duration) -> Self {
|
||||
Self {
|
||||
path,
|
||||
retention: chrono::Duration::from_std(retention).expect("invalid retention duration"),
|
||||
last_rotation: Utc.timestamp_opt(0, 0).unwrap(),
|
||||
next_rotation: Utc.timestamp_opt(0, 0).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_sync(mut self) -> Result<std::thread::JoinHandle<()>, std::io::Error> {
|
||||
std::fs::create_dir_all(&self.path)?;
|
||||
self.rotate_sync()?;
|
||||
|
||||
Ok(std::thread::spawn(|| self.run_sync()))
|
||||
}
|
||||
|
||||
fn run_sync(mut self) {
|
||||
loop {
|
||||
let now = Utc::now();
|
||||
|
||||
if self.next_rotation - now > Duration::seconds(5) {
|
||||
std::thread::sleep(
|
||||
(self.next_rotation - now - Duration::seconds(5))
|
||||
.max(TimeDelta::default())
|
||||
.to_std()
|
||||
.expect("bad duration"),
|
||||
);
|
||||
} else if now.ordinal() != self.last_rotation.ordinal() {
|
||||
if let Err(err) = self.rotate_sync() {
|
||||
tracing::error!(?err, "failed logs rotation");
|
||||
}
|
||||
} else {
|
||||
std::thread::sleep(std::time::Duration::from_millis(250));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rotate_sync(&mut self) -> Result<(), std::io::Error> {
|
||||
self.last_rotation = Utc::now();
|
||||
self.next_rotation = Utc.from_utc_datetime(
|
||||
&(self
|
||||
.last_rotation
|
||||
.date_naive()
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.ok_or_else(|| {
|
||||
std::io::Error::new(std::io::ErrorKind::Other, "invalid date")
|
||||
})?
|
||||
+ Duration::days(1)),
|
||||
);
|
||||
|
||||
let file_name = format!("log-{}", self.last_rotation.format("%m-%d-%y"));
|
||||
let path = self.path.join(file_name);
|
||||
|
||||
let log_file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)?;
|
||||
let log_fd = log_file.as_raw_fd();
|
||||
|
||||
unsafe {
|
||||
libc::dup2(log_fd, libc::STDOUT_FILENO);
|
||||
libc::dup2(log_fd, libc::STDERR_FILENO);
|
||||
}
|
||||
|
||||
self.prune_sync()
|
||||
}
|
||||
|
||||
fn prune_sync(&self) -> Result<(), std::io::Error> {
|
||||
let mut entries = std::fs::read_dir(&self.path)?;
|
||||
let mut pruned = 0;
|
||||
|
||||
while let Some(entry) = entries.next() {
|
||||
let entry = entry?;
|
||||
let metadata = entry.metadata()?;
|
||||
let modified = chrono::DateTime::<Utc>::from(metadata.modified()?);
|
||||
if modified < Utc::now() - self.retention {
|
||||
pruned += 1;
|
||||
let _ = std::fs::remove_file(entry.path());
|
||||
}
|
||||
}
|
||||
|
||||
if pruned != 0 {
|
||||
tracing::debug!("pruned {pruned} log files");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
131
server/packages/sandbox-agent/src/server_logs/windows.rs
Normal file
131
server/packages/sandbox-agent/src/server_logs/windows.rs
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
use chrono::{Datelike, Duration, TimeDelta, TimeZone, Utc};
|
||||
use windows::{
|
||||
core::PCSTR,
|
||||
Win32::{
|
||||
Foundation::{HANDLE, INVALID_HANDLE_VALUE},
|
||||
Storage::FileSystem::{
|
||||
CreateFileA, FILE_ATTRIBUTE_NORMAL, FILE_GENERIC_WRITE, FILE_SHARE_READ, OPEN_ALWAYS,
|
||||
},
|
||||
System::Console::{SetStdHandle, STD_ERROR_HANDLE, STD_OUTPUT_HANDLE},
|
||||
},
|
||||
};
|
||||
|
||||
pub struct ServerLogs {
|
||||
path: PathBuf,
|
||||
retention: Duration,
|
||||
|
||||
last_rotation: chrono::DateTime<Utc>,
|
||||
next_rotation: chrono::DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl ServerLogs {
|
||||
pub fn new(path: PathBuf, retention: std::time::Duration) -> Self {
|
||||
Self {
|
||||
path,
|
||||
retention: chrono::Duration::from_std(retention).expect("invalid retention duration"),
|
||||
last_rotation: Utc.timestamp_opt(0, 0).unwrap(),
|
||||
next_rotation: Utc.timestamp_opt(0, 0).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_sync(mut self) -> Result<std::thread::JoinHandle<()>, std::io::Error> {
|
||||
std::fs::create_dir_all(&self.path)?;
|
||||
self.rotate_sync()?;
|
||||
|
||||
Ok(std::thread::spawn(|| self.run_sync()))
|
||||
}
|
||||
|
||||
fn run_sync(mut self) {
|
||||
loop {
|
||||
let now = Utc::now();
|
||||
|
||||
if self.next_rotation - now > Duration::seconds(5) {
|
||||
std::thread::sleep(
|
||||
(self.next_rotation - now - Duration::seconds(5))
|
||||
.max(TimeDelta::default())
|
||||
.to_std()
|
||||
.expect("bad duration"),
|
||||
);
|
||||
} else if now.ordinal() != self.last_rotation.ordinal() {
|
||||
if let Err(err) = self.rotate_sync() {
|
||||
tracing::error!(?err, "failed logs rotation");
|
||||
}
|
||||
} else {
|
||||
std::thread::sleep(std::time::Duration::from_millis(250));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rotate_sync(&mut self) -> Result<(), std::io::Error> {
|
||||
self.last_rotation = Utc::now();
|
||||
self.next_rotation = Utc.from_utc_datetime(
|
||||
&(self
|
||||
.last_rotation
|
||||
.date_naive()
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.ok_or_else(|| {
|
||||
std::io::Error::new(std::io::ErrorKind::Other, "invalid date")
|
||||
})?
|
||||
+ Duration::days(1)),
|
||||
);
|
||||
|
||||
let file_name = format!("log-{}", self.last_rotation.format("%m-%d-%y"));
|
||||
let path = self.path.join(file_name);
|
||||
|
||||
let path_str = path
|
||||
.to_str()
|
||||
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::Other, "invalid path"))?;
|
||||
let path_cstr = std::ffi::CString::new(path_str)
|
||||
.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err.to_string()))?;
|
||||
|
||||
unsafe {
|
||||
let file_handle = CreateFileA(
|
||||
PCSTR(path_cstr.as_ptr() as *const u8),
|
||||
FILE_GENERIC_WRITE.0,
|
||||
FILE_SHARE_READ,
|
||||
None,
|
||||
OPEN_ALWAYS,
|
||||
FILE_ATTRIBUTE_NORMAL,
|
||||
HANDLE(std::ptr::null_mut()),
|
||||
)
|
||||
.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err.to_string()))?;
|
||||
|
||||
if file_handle == INVALID_HANDLE_VALUE {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
"failed to create log file",
|
||||
));
|
||||
}
|
||||
|
||||
SetStdHandle(STD_OUTPUT_HANDLE, file_handle)
|
||||
.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err.to_string()))?;
|
||||
SetStdHandle(STD_ERROR_HANDLE, file_handle)
|
||||
.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err.to_string()))?;
|
||||
}
|
||||
|
||||
self.prune_sync()
|
||||
}
|
||||
|
||||
fn prune_sync(&self) -> Result<(), std::io::Error> {
|
||||
let mut entries = std::fs::read_dir(&self.path)?;
|
||||
let mut pruned = 0;
|
||||
|
||||
while let Some(entry) = entries.next() {
|
||||
let entry = entry?;
|
||||
let metadata = entry.metadata()?;
|
||||
let modified = chrono::DateTime::<Utc>::from(metadata.modified()?);
|
||||
if modified < Utc::now() - self.retention {
|
||||
pruned += 1;
|
||||
let _ = std::fs::remove_file(entry.path());
|
||||
}
|
||||
}
|
||||
|
||||
if pruned != 0 {
|
||||
tracing::debug!("pruned {pruned} log files");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
# OpenCode Compatibility Tests
|
||||
|
||||
These tests verify that sandbox-agent exposes OpenCode-compatible API endpoints under `/opencode` and that they are usable with the official [`@opencode-ai/sdk`](https://www.npmjs.com/package/@opencode-ai/sdk) TypeScript SDK.
|
||||
|
||||
## Purpose
|
||||
|
||||
The goal is to enable sandbox-agent to be used as a drop-in replacement for OpenCode's server, allowing tools and integrations built for OpenCode to work seamlessly with sandbox-agent.
|
||||
|
||||
## Test Coverage
|
||||
|
||||
The tests cover the following OpenCode API surfaces:
|
||||
|
||||
### Session Management (`session.test.ts`)
|
||||
- `POST /session` - Create a new session
|
||||
- `GET /session` - List all sessions
|
||||
- `GET /session/{id}` - Get session details
|
||||
- `PATCH /session/{id}` - Update session properties
|
||||
- `DELETE /session/{id}` - Delete a session
|
||||
|
||||
### Messaging (`messaging.test.ts`)
|
||||
- `POST /session/{id}/message` - Send a prompt to the session
|
||||
- `POST /session/{id}/prompt_async` - Send async prompt
|
||||
- `GET /session/{id}/message` - List messages
|
||||
- `GET /session/{id}/message/{messageID}` - Get specific message
|
||||
- `POST /session/{id}/abort` - Abort session
|
||||
|
||||
### Event Streaming (`events.test.ts`)
|
||||
- `GET /event` - Subscribe to all events (SSE)
|
||||
- `GET /global/event` - Subscribe to global events (SSE)
|
||||
- `GET /session/status` - Get session status
|
||||
|
||||
### Permissions (`permissions.test.ts`)
|
||||
- `POST /session/{id}/permissions/{permissionID}` - Respond to permission request
|
||||
|
||||
### OpenAPI Coverage (Rust)
|
||||
- `cargo test -p sandbox-agent --test opencode_openapi`
|
||||
- Compares the Utoipa-generated OpenCode spec against `resources/agent-schemas/artifacts/openapi/opencode.json`
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# From this directory
|
||||
pnpm test
|
||||
|
||||
# Or from the workspace root
|
||||
pnpm --filter @sandbox-agent/opencode-compat-tests test
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Build the sandbox-agent binary:
|
||||
```bash
|
||||
cargo build -p sandbox-agent
|
||||
```
|
||||
|
||||
2. Or set `SANDBOX_AGENT_BIN` environment variable to point to a pre-built binary.
|
||||
|
||||
## Test Approach
|
||||
|
||||
Each test:
|
||||
1. Spawns a fresh sandbox-agent instance on a unique port
|
||||
2. Uses `createOpencodeClient` from `@opencode-ai/sdk` to connect
|
||||
3. Tests the OpenCode-compatible endpoints
|
||||
4. Cleans up the server instance
|
||||
|
||||
This ensures tests are isolated and can run in parallel.
|
||||
|
||||
## Current Status
|
||||
|
||||
These tests validate the `/opencode` compatibility layer and should pass when the endpoints are mounted and responding with OpenCode-compatible shapes.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
To make sandbox-agent OpenCode-compatible, the following needs to be implemented:
|
||||
|
||||
1. **OpenCode API Routes** - Exposed under `/opencode`
|
||||
2. **Request/Response Mapping** - OpenCode response shapes with stubbed data where needed
|
||||
3. **SSE Event Streaming** - OpenCode event format for SSE
|
||||
4. **Permission Handling** - Accepts OpenCode permission replies
|
||||
|
||||
See the OpenCode SDK types at `/home/nathan/misc/opencode/packages/sdk/js/src/gen/types.gen.ts` for the expected API shapes.
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
/**
|
||||
* Tests for OpenCode-compatible event streaming endpoints.
|
||||
*
|
||||
* These tests verify that sandbox-agent exposes OpenCode-compatible SSE event
|
||||
* endpoints that can be used with the official OpenCode SDK.
|
||||
*
|
||||
* Expected endpoints:
|
||||
* - GET /event - Subscribe to all events (SSE)
|
||||
* - GET /global/event - Subscribe to global events (SSE)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest";
|
||||
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk";
|
||||
import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn";
|
||||
|
||||
describe("OpenCode-compatible Event Streaming", () => {
|
||||
let handle: SandboxAgentHandle;
|
||||
let client: OpencodeClient;
|
||||
|
||||
beforeAll(async () => {
|
||||
await buildSandboxAgent();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
handle = await spawnSandboxAgent({ opencodeCompat: true });
|
||||
client = createOpencodeClient({
|
||||
baseUrl: `${handle.baseUrl}/opencode`,
|
||||
headers: { Authorization: `Bearer ${handle.token}` },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await handle?.dispose();
|
||||
});
|
||||
|
||||
describe("event.subscribe", () => {
|
||||
it("should connect to SSE endpoint", async () => {
|
||||
// The event.subscribe returns an SSE stream
|
||||
const response = await client.event.subscribe();
|
||||
|
||||
expect(response).toBeDefined();
|
||||
expect((response as any).stream).toBeDefined();
|
||||
});
|
||||
|
||||
it("should receive session.created event when session is created", async () => {
|
||||
const events: any[] = [];
|
||||
|
||||
// Start listening for events
|
||||
const eventStream = await client.event.subscribe();
|
||||
|
||||
// Set up event collection with timeout
|
||||
const collectEvents = new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(resolve, 5000);
|
||||
(async () => {
|
||||
try {
|
||||
for await (const event of (eventStream as any).stream) {
|
||||
events.push(event);
|
||||
if (event.type === "session.created") {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Stream ended or errored
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
// Create a session
|
||||
await client.session.create({ body: { title: "Event Test" } });
|
||||
|
||||
await collectEvents;
|
||||
|
||||
// Should have received at least one session.created event
|
||||
const sessionCreatedEvent = events.find((e) => e.type === "session.created");
|
||||
expect(sessionCreatedEvent).toBeDefined();
|
||||
});
|
||||
|
||||
it("should receive message.part.updated events during prompt", async () => {
|
||||
const events: any[] = [];
|
||||
|
||||
// Create a session first
|
||||
const session = await client.session.create();
|
||||
const sessionId = session.data?.id!;
|
||||
|
||||
// Start listening for events
|
||||
const eventStream = await client.event.subscribe();
|
||||
|
||||
const collectEvents = new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(resolve, 10000);
|
||||
(async () => {
|
||||
try {
|
||||
for await (const event of (eventStream as any).stream) {
|
||||
events.push(event);
|
||||
// Look for message part updates or completion
|
||||
if (
|
||||
event.type === "message.part.updated" ||
|
||||
event.type === "session.idle"
|
||||
) {
|
||||
if (events.length >= 3) {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Stream ended
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
// Send a prompt
|
||||
await client.session.prompt({
|
||||
path: { id: sessionId },
|
||||
body: {
|
||||
model: { providerID: "sandbox-agent", modelID: "mock" },
|
||||
parts: [{ type: "text", text: "Say hello" }],
|
||||
},
|
||||
});
|
||||
|
||||
await collectEvents;
|
||||
|
||||
// Should have received some events
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("global.event", () => {
|
||||
it("should connect to global SSE endpoint", async () => {
|
||||
const response = await client.global.event();
|
||||
|
||||
expect(response).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("session.status", () => {
|
||||
it("should return session status", async () => {
|
||||
const session = await client.session.create();
|
||||
const sessionId = session.data?.id!;
|
||||
|
||||
const response = await client.session.status();
|
||||
|
||||
expect(response.data).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,237 @@
|
|||
/**
|
||||
* Utilities for spawning sandbox-agent for OpenCode compatibility testing.
|
||||
* Mirrors the patterns from sdks/typescript/src/spawn.ts
|
||||
*/
|
||||
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { createServer, type AddressInfo, type Server } from "node:net";
|
||||
import { existsSync } from "node:fs";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export interface SandboxAgentHandle {
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
child: ChildProcess;
|
||||
dispose: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the sandbox-agent binary in common locations
|
||||
*/
|
||||
function findBinary(): string | null {
|
||||
// Check environment variable first
|
||||
if (process.env.SANDBOX_AGENT_BIN) {
|
||||
const path = process.env.SANDBOX_AGENT_BIN;
|
||||
if (existsSync(path)) {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
// Check cargo build outputs (relative to tests/opencode-compat/helpers)
|
||||
const cargoPaths = [
|
||||
resolve(__dirname, "../../../../../../target/debug/sandbox-agent"),
|
||||
resolve(__dirname, "../../../../../../target/release/sandbox-agent"),
|
||||
];
|
||||
|
||||
for (const p of cargoPaths) {
|
||||
if (existsSync(p)) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a free port on the given host
|
||||
*/
|
||||
async function getFreePort(host: string): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createServer();
|
||||
server.unref();
|
||||
server.on("error", reject);
|
||||
server.listen(0, host, () => {
|
||||
const address = server.address() as AddressInfo;
|
||||
server.close(() => resolve(address.port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the server to become healthy
|
||||
*/
|
||||
async function waitForHealth(
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
timeoutMs: number,
|
||||
child: ChildProcess
|
||||
): Promise<void> {
|
||||
const start = Date.now();
|
||||
let lastError: string | undefined;
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (child.exitCode !== null) {
|
||||
throw new Error("sandbox-agent exited before becoming healthy");
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/v1/health`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
lastError = `status ${response.status}`;
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for sandbox-agent health (${lastError ?? "unknown"})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for child process to exit
|
||||
*/
|
||||
async function waitForExit(child: ChildProcess, timeoutMs: number): Promise<boolean> {
|
||||
if (child.exitCode !== null) {
|
||||
return true;
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => resolve(false), timeoutMs);
|
||||
child.once("exit", () => {
|
||||
clearTimeout(timer);
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export interface SpawnOptions {
|
||||
host?: string;
|
||||
port?: number;
|
||||
token?: string;
|
||||
timeoutMs?: number;
|
||||
env?: Record<string, string>;
|
||||
/** Enable OpenCode compatibility mode */
|
||||
opencodeCompat?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a sandbox-agent instance for testing.
|
||||
* Each test should spawn its own instance on a unique port.
|
||||
*/
|
||||
export async function spawnSandboxAgent(options: SpawnOptions = {}): Promise<SandboxAgentHandle> {
|
||||
const binaryPath = findBinary();
|
||||
if (!binaryPath) {
|
||||
throw new Error(
|
||||
"sandbox-agent binary not found. Run 'cargo build -p sandbox-agent' first or set SANDBOX_AGENT_BIN."
|
||||
);
|
||||
}
|
||||
|
||||
const host = options.host ?? "127.0.0.1";
|
||||
const port = options.port ?? (await getFreePort(host));
|
||||
const token = options.token ?? randomBytes(24).toString("hex");
|
||||
const timeoutMs = options.timeoutMs ?? 30_000;
|
||||
|
||||
const args = ["server", "--host", host, "--port", String(port), "--token", token];
|
||||
|
||||
const compatEnv = {
|
||||
OPENCODE_COMPAT_FIXED_TIME_MS: "1700000000000",
|
||||
OPENCODE_COMPAT_DIRECTORY: "/workspace",
|
||||
OPENCODE_COMPAT_WORKTREE: "/workspace",
|
||||
OPENCODE_COMPAT_HOME: "/home/opencode",
|
||||
OPENCODE_COMPAT_STATE: "/state/opencode",
|
||||
OPENCODE_COMPAT_CONFIG: "/config/opencode",
|
||||
OPENCODE_COMPAT_BRANCH: "main",
|
||||
};
|
||||
|
||||
const child = spawn(binaryPath, args, {
|
||||
stdio: "pipe",
|
||||
env: {
|
||||
...process.env,
|
||||
...compatEnv,
|
||||
...(options.env ?? {}),
|
||||
},
|
||||
});
|
||||
|
||||
// Collect stderr for debugging
|
||||
let stderr = "";
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
const text = chunk.toString();
|
||||
stderr += text;
|
||||
if (process.env.SANDBOX_AGENT_TEST_LOGS) {
|
||||
process.stderr.write(text);
|
||||
}
|
||||
});
|
||||
if (process.env.SANDBOX_AGENT_TEST_LOGS) {
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
process.stderr.write(chunk.toString());
|
||||
});
|
||||
}
|
||||
|
||||
const baseUrl = `http://${host}:${port}`;
|
||||
|
||||
try {
|
||||
await waitForHealth(baseUrl, token, timeoutMs, child);
|
||||
} catch (err) {
|
||||
child.kill("SIGKILL");
|
||||
if (stderr) {
|
||||
throw new Error(`${err}. Stderr: ${stderr}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const dispose = async () => {
|
||||
if (child.exitCode !== null) {
|
||||
return;
|
||||
}
|
||||
child.kill("SIGTERM");
|
||||
const exited = await waitForExit(child, 5_000);
|
||||
if (!exited) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
};
|
||||
|
||||
return { baseUrl, token, child, dispose };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the sandbox-agent binary if it doesn't exist
|
||||
*/
|
||||
export async function buildSandboxAgent(): Promise<void> {
|
||||
const binaryPath = findBinary();
|
||||
if (binaryPath) {
|
||||
console.log(`sandbox-agent binary found at: ${binaryPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Building sandbox-agent...");
|
||||
const projectRoot = resolve(__dirname, "../../../../../..");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn("cargo", ["build", "-p", "sandbox-agent"], {
|
||||
cwd: projectRoot,
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
SANDBOX_AGENT_SKIP_INSPECTOR: "1",
|
||||
},
|
||||
});
|
||||
|
||||
proc.on("exit", (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`cargo build failed with code ${code}`));
|
||||
}
|
||||
});
|
||||
|
||||
proc.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
/**
|
||||
* Tests for OpenCode-compatible messaging endpoints.
|
||||
*
|
||||
* These tests verify that sandbox-agent exposes OpenCode-compatible message/prompt
|
||||
* endpoints that can be used with the official OpenCode SDK.
|
||||
*
|
||||
* Expected endpoints:
|
||||
* - POST /session/{id}/message - Send a prompt to the session
|
||||
* - GET /session/{id}/message - List messages in a session
|
||||
* - GET /session/{id}/message/{messageID} - Get a specific message
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest";
|
||||
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk";
|
||||
import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn";
|
||||
|
||||
describe("OpenCode-compatible Messaging API", () => {
|
||||
let handle: SandboxAgentHandle;
|
||||
let client: OpencodeClient;
|
||||
let sessionId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await buildSandboxAgent();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
handle = await spawnSandboxAgent({ opencodeCompat: true });
|
||||
client = createOpencodeClient({
|
||||
baseUrl: `${handle.baseUrl}/opencode`,
|
||||
headers: { Authorization: `Bearer ${handle.token}` },
|
||||
});
|
||||
|
||||
// Create a session for messaging tests
|
||||
const session = await client.session.create();
|
||||
sessionId = session.data?.id!;
|
||||
expect(sessionId).toBeDefined();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await handle?.dispose();
|
||||
});
|
||||
|
||||
describe("session.prompt", () => {
|
||||
it("should send a message to the session", async () => {
|
||||
const response = await client.session.prompt({
|
||||
path: { id: sessionId },
|
||||
body: {
|
||||
model: { providerID: "sandbox-agent", modelID: "mock" },
|
||||
parts: [{ type: "text", text: "Hello, world!" }],
|
||||
},
|
||||
});
|
||||
|
||||
// The response should return a message or acknowledgement
|
||||
expect(response.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should accept text-only prompt", async () => {
|
||||
const response = await client.session.prompt({
|
||||
path: { id: sessionId },
|
||||
body: {
|
||||
model: { providerID: "sandbox-agent", modelID: "mock" },
|
||||
parts: [{ type: "text", text: "Say hello" }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("session.promptAsync", () => {
|
||||
it("should send async prompt and return immediately", async () => {
|
||||
const response = await client.session.promptAsync({
|
||||
path: { id: sessionId },
|
||||
body: {
|
||||
model: { providerID: "sandbox-agent", modelID: "mock" },
|
||||
parts: [{ type: "text", text: "Process this asynchronously" }],
|
||||
},
|
||||
});
|
||||
|
||||
// Should return quickly without waiting for completion
|
||||
expect(response.error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("session.messages", () => {
|
||||
it("should return empty list for new session", async () => {
|
||||
const response = await client.session.messages({
|
||||
path: { id: sessionId },
|
||||
});
|
||||
|
||||
expect(response.data).toBeDefined();
|
||||
expect(Array.isArray(response.data)).toBe(true);
|
||||
});
|
||||
|
||||
it("should list messages after sending a prompt", async () => {
|
||||
await client.session.prompt({
|
||||
path: { id: sessionId },
|
||||
body: {
|
||||
model: { providerID: "sandbox-agent", modelID: "mock" },
|
||||
parts: [{ type: "text", text: "Test message" }],
|
||||
},
|
||||
});
|
||||
|
||||
const response = await client.session.messages({
|
||||
path: { id: sessionId },
|
||||
});
|
||||
|
||||
expect(response.data).toBeDefined();
|
||||
expect(response.data?.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("session.message (get specific)", () => {
|
||||
it("should retrieve a specific message by ID", async () => {
|
||||
// Send a prompt first
|
||||
await client.session.prompt({
|
||||
path: { id: sessionId },
|
||||
body: {
|
||||
model: { providerID: "sandbox-agent", modelID: "mock" },
|
||||
parts: [{ type: "text", text: "Test" }],
|
||||
},
|
||||
});
|
||||
|
||||
// Get messages to find a message ID
|
||||
const messagesResponse = await client.session.messages({
|
||||
path: { id: sessionId },
|
||||
});
|
||||
const messageId = messagesResponse.data?.[0]?.id;
|
||||
|
||||
if (messageId) {
|
||||
const response = await client.session.message({
|
||||
path: { id: sessionId, messageID: messageId },
|
||||
});
|
||||
|
||||
expect(response.data).toBeDefined();
|
||||
expect(response.data?.id).toBe(messageId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("session.abort", () => {
|
||||
it("should abort an in-progress session", async () => {
|
||||
// Start an async prompt
|
||||
await client.session.promptAsync({
|
||||
path: { id: sessionId },
|
||||
body: {
|
||||
model: { providerID: "sandbox-agent", modelID: "mock" },
|
||||
parts: [{ type: "text", text: "Long running task" }],
|
||||
},
|
||||
});
|
||||
|
||||
// Abort the session
|
||||
const response = await client.session.abort({
|
||||
path: { id: sessionId },
|
||||
});
|
||||
|
||||
expect(response.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should handle abort on idle session gracefully", async () => {
|
||||
// Abort without starting any work
|
||||
const response = await client.session.abort({
|
||||
path: { id: sessionId },
|
||||
});
|
||||
|
||||
// Should not error, even if there's nothing to abort
|
||||
expect(response.error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"name": "@sandbox-agent/opencode-compat-tests",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "^1.1.21"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
/**
|
||||
* Tests for OpenCode-compatible permission endpoints.
|
||||
*
|
||||
* These tests verify that sandbox-agent exposes OpenCode-compatible permission
|
||||
* handling endpoints that can be used with the official OpenCode SDK.
|
||||
*
|
||||
* Expected endpoints:
|
||||
* - POST /session/{id}/permissions/{permissionID} - Respond to a permission request
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest";
|
||||
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2";
|
||||
import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn";
|
||||
|
||||
describe("OpenCode-compatible Permission API", () => {
|
||||
let handle: SandboxAgentHandle;
|
||||
let client: OpencodeClient;
|
||||
let sessionId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await buildSandboxAgent();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
handle = await spawnSandboxAgent({ opencodeCompat: true });
|
||||
client = createOpencodeClient({
|
||||
baseUrl: `${handle.baseUrl}/opencode`,
|
||||
headers: { Authorization: `Bearer ${handle.token}` },
|
||||
});
|
||||
|
||||
// Create a session
|
||||
const session = await client.session.create();
|
||||
sessionId = session.data?.id!;
|
||||
expect(sessionId).toBeDefined();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await handle?.dispose();
|
||||
});
|
||||
|
||||
const permissionPrompt = "permission";
|
||||
|
||||
async function waitForPermissionRequest(timeoutMs = 10_000) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const list = await client.permission.list();
|
||||
const request = list.data?.[0];
|
||||
if (request) {
|
||||
return request;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
}
|
||||
throw new Error("Timed out waiting for permission request");
|
||||
}
|
||||
|
||||
describe("permission.reply (global)", () => {
|
||||
it("should receive permission.asked and reply via global endpoint", async () => {
|
||||
await client.session.prompt({
|
||||
sessionID: sessionId,
|
||||
model: { providerID: "sandbox-agent", modelID: "mock" },
|
||||
parts: [{ type: "text", text: permissionPrompt }],
|
||||
});
|
||||
|
||||
const asked = await waitForPermissionRequest();
|
||||
const requestId = asked?.id;
|
||||
expect(requestId).toBeDefined();
|
||||
|
||||
const response = await client.permission.reply({
|
||||
requestID: requestId,
|
||||
reply: "once",
|
||||
});
|
||||
expect(response.error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("postSessionIdPermissionsPermissionId (session)", () => {
|
||||
it("should accept permission response for a session", async () => {
|
||||
await client.session.prompt({
|
||||
sessionID: sessionId,
|
||||
model: { providerID: "sandbox-agent", modelID: "mock" },
|
||||
parts: [{ type: "text", text: permissionPrompt }],
|
||||
});
|
||||
|
||||
const asked = await waitForPermissionRequest();
|
||||
const requestId = asked?.id;
|
||||
expect(requestId).toBeDefined();
|
||||
|
||||
const response = await client.permission.respond({
|
||||
sessionID: sessionId,
|
||||
permissionID: requestId,
|
||||
response: "allow",
|
||||
});
|
||||
|
||||
expect(response.error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
/**
|
||||
* Tests for OpenCode-compatible question endpoints.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest";
|
||||
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2";
|
||||
import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn";
|
||||
|
||||
describe("OpenCode-compatible Question API", () => {
|
||||
let handle: SandboxAgentHandle;
|
||||
let client: OpencodeClient;
|
||||
let sessionId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await buildSandboxAgent();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
handle = await spawnSandboxAgent({ opencodeCompat: true });
|
||||
client = createOpencodeClient({
|
||||
baseUrl: `${handle.baseUrl}/opencode`,
|
||||
headers: { Authorization: `Bearer ${handle.token}` },
|
||||
});
|
||||
|
||||
const session = await client.session.create();
|
||||
sessionId = session.data?.id!;
|
||||
expect(sessionId).toBeDefined();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await handle?.dispose();
|
||||
});
|
||||
|
||||
const questionPrompt = "question";
|
||||
|
||||
async function waitForQuestionRequest(timeoutMs = 10_000) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const list = await client.question.list();
|
||||
const request = list.data?.[0];
|
||||
if (request) {
|
||||
return request;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
}
|
||||
throw new Error("Timed out waiting for question request");
|
||||
}
|
||||
|
||||
it("should ask a question and accept a reply", async () => {
|
||||
await client.session.prompt({
|
||||
sessionID: sessionId,
|
||||
model: { providerID: "sandbox-agent", modelID: "mock" },
|
||||
parts: [{ type: "text", text: questionPrompt }],
|
||||
});
|
||||
|
||||
const asked = await waitForQuestionRequest();
|
||||
const requestId = asked?.id;
|
||||
expect(requestId).toBeDefined();
|
||||
|
||||
const replyResponse = await client.question.reply({
|
||||
requestID: requestId,
|
||||
answers: [["Yes"]],
|
||||
});
|
||||
expect(replyResponse.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should allow rejecting a question", async () => {
|
||||
await client.session.prompt({
|
||||
sessionID: sessionId,
|
||||
model: { providerID: "sandbox-agent", modelID: "mock" },
|
||||
parts: [{ type: "text", text: questionPrompt }],
|
||||
});
|
||||
|
||||
const asked = await waitForQuestionRequest();
|
||||
const requestId = asked?.id;
|
||||
expect(requestId).toBeDefined();
|
||||
|
||||
const rejectResponse = await client.question.reject({
|
||||
requestID: requestId,
|
||||
});
|
||||
expect(rejectResponse.error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
/**
|
||||
* Tests for OpenCode-compatible session management endpoints.
|
||||
*
|
||||
* These tests verify that sandbox-agent exposes OpenCode-compatible API endpoints
|
||||
* that can be used with the official OpenCode SDK.
|
||||
*
|
||||
* Expected endpoints:
|
||||
* - POST /session - Create a new session
|
||||
* - GET /session - List all sessions
|
||||
* - GET /session/{id} - Get session details
|
||||
* - PATCH /session/{id} - Update session properties
|
||||
* - DELETE /session/{id} - Delete a session
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from "vitest";
|
||||
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk";
|
||||
import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn";
|
||||
|
||||
describe("OpenCode-compatible Session API", () => {
|
||||
let handle: SandboxAgentHandle;
|
||||
let client: OpencodeClient;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Build the binary if needed
|
||||
await buildSandboxAgent();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Spawn a fresh sandbox-agent instance for each test
|
||||
handle = await spawnSandboxAgent({ opencodeCompat: true });
|
||||
client = createOpencodeClient({
|
||||
baseUrl: `${handle.baseUrl}/opencode`,
|
||||
headers: { Authorization: `Bearer ${handle.token}` },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await handle?.dispose();
|
||||
});
|
||||
|
||||
describe("session.create", () => {
|
||||
it("should create a new session", async () => {
|
||||
const response = await client.session.create();
|
||||
|
||||
expect(response.data).toBeDefined();
|
||||
expect(response.data?.id).toBeDefined();
|
||||
expect(typeof response.data?.id).toBe("string");
|
||||
expect(response.data?.id.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should create session with custom title", async () => {
|
||||
const response = await client.session.create({
|
||||
body: { title: "Test Session" },
|
||||
});
|
||||
|
||||
expect(response.data).toBeDefined();
|
||||
expect(response.data?.title).toBe("Test Session");
|
||||
});
|
||||
|
||||
it("should assign unique IDs to each session", async () => {
|
||||
const session1 = await client.session.create();
|
||||
const session2 = await client.session.create();
|
||||
|
||||
expect(session1.data?.id).not.toBe(session2.data?.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("session.list", () => {
|
||||
it("should return empty list when no sessions exist", async () => {
|
||||
const response = await client.session.list();
|
||||
|
||||
expect(response.data).toBeDefined();
|
||||
expect(Array.isArray(response.data)).toBe(true);
|
||||
expect(response.data?.length).toBe(0);
|
||||
});
|
||||
|
||||
it("should list created sessions", async () => {
|
||||
// Create some sessions
|
||||
await client.session.create({ body: { title: "Session 1" } });
|
||||
await client.session.create({ body: { title: "Session 2" } });
|
||||
|
||||
const response = await client.session.list();
|
||||
|
||||
expect(response.data).toBeDefined();
|
||||
expect(response.data?.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("session.get", () => {
|
||||
it("should retrieve session by ID", async () => {
|
||||
const created = await client.session.create({ body: { title: "Test" } });
|
||||
const sessionId = created.data?.id;
|
||||
expect(sessionId).toBeDefined();
|
||||
|
||||
const response = await client.session.get({ path: { id: sessionId! } });
|
||||
|
||||
expect(response.data).toBeDefined();
|
||||
expect(response.data?.id).toBe(sessionId);
|
||||
expect(response.data?.title).toBe("Test");
|
||||
});
|
||||
|
||||
it("should return error for non-existent session", async () => {
|
||||
const response = await client.session.get({
|
||||
path: { id: "non-existent-session-id" },
|
||||
});
|
||||
|
||||
expect(response.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("session.update", () => {
|
||||
it("should update session title", async () => {
|
||||
const created = await client.session.create({ body: { title: "Original" } });
|
||||
const sessionId = created.data?.id!;
|
||||
|
||||
await client.session.update({
|
||||
path: { id: sessionId },
|
||||
body: { title: "Updated" },
|
||||
});
|
||||
|
||||
const response = await client.session.get({ path: { id: sessionId } });
|
||||
expect(response.data?.title).toBe("Updated");
|
||||
});
|
||||
});
|
||||
|
||||
describe("session.delete", () => {
|
||||
it("should delete a session", async () => {
|
||||
const created = await client.session.create();
|
||||
const sessionId = created.data?.id!;
|
||||
|
||||
await client.session.delete({ path: { id: sessionId } });
|
||||
|
||||
const response = await client.session.get({ path: { id: sessionId } });
|
||||
expect(response.error).toBeDefined();
|
||||
});
|
||||
|
||||
it("should not affect other sessions when one is deleted", async () => {
|
||||
const session1 = await client.session.create({ body: { title: "Keep" } });
|
||||
const session2 = await client.session.create({ body: { title: "Delete" } });
|
||||
|
||||
await client.session.delete({ path: { id: session2.data?.id! } });
|
||||
|
||||
const response = await client.session.get({ path: { id: session1.data?.id! } });
|
||||
expect(response.data).toBeDefined();
|
||||
expect(response.data?.title).toBe("Keep");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
/**
|
||||
* Tests for OpenCode-compatible tool calls and file actions.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest";
|
||||
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk";
|
||||
import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn";
|
||||
|
||||
describe("OpenCode-compatible Tool + File Actions", () => {
|
||||
let handle: SandboxAgentHandle;
|
||||
let client: OpencodeClient;
|
||||
let sessionId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await buildSandboxAgent();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
handle = await spawnSandboxAgent({ opencodeCompat: true });
|
||||
client = createOpencodeClient({
|
||||
baseUrl: `${handle.baseUrl}/opencode`,
|
||||
headers: { Authorization: `Bearer ${handle.token}` },
|
||||
});
|
||||
|
||||
const session = await client.session.create();
|
||||
sessionId = session.data?.id!;
|
||||
expect(sessionId).toBeDefined();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await handle?.dispose();
|
||||
});
|
||||
|
||||
it("should emit tool and file parts plus file.edited events", async () => {
|
||||
const eventStream = await client.event.subscribe();
|
||||
const tracker = {
|
||||
tool: false,
|
||||
file: false,
|
||||
edited: false,
|
||||
};
|
||||
|
||||
const waiter = new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error("Timed out waiting for tool events")), 15_000);
|
||||
(async () => {
|
||||
try {
|
||||
for await (const event of (eventStream as any).stream) {
|
||||
if (event.type === "message.part.updated") {
|
||||
const part = event.properties?.part;
|
||||
if (part?.type === "tool") {
|
||||
tracker.tool = true;
|
||||
}
|
||||
if (part?.type === "file") {
|
||||
tracker.file = true;
|
||||
}
|
||||
}
|
||||
if (event.type === "file.edited") {
|
||||
tracker.edited = true;
|
||||
}
|
||||
if (tracker.tool && tracker.file && tracker.edited) {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
await client.session.prompt({
|
||||
path: { id: sessionId },
|
||||
body: {
|
||||
model: { providerID: "sandbox-agent", modelID: "mock" },
|
||||
parts: [{ type: "text", text: "tool" }],
|
||||
},
|
||||
});
|
||||
|
||||
await waiter;
|
||||
expect(tracker.tool).toBe(true);
|
||||
expect(tracker.file).toBe(true);
|
||||
expect(tracker.edited).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": false,
|
||||
"noEmit": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
import { resolve } from "node:path";
|
||||
import { realpathSync } from "node:fs";
|
||||
|
||||
// Resolve the actual SDK path through pnpm's symlink structure
|
||||
function resolveSdkPath(): string {
|
||||
try {
|
||||
// Try to resolve through the local node_modules symlink
|
||||
const localPath = resolve(__dirname, "node_modules/@opencode-ai/sdk");
|
||||
const realPath = realpathSync(localPath);
|
||||
return resolve(realPath, "dist");
|
||||
} catch {
|
||||
// Fallback to root node_modules
|
||||
return resolve(__dirname, "../../../../../node_modules/@opencode-ai/sdk/dist");
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["**/*.test.ts"],
|
||||
testTimeout: 60_000,
|
||||
hookTimeout: 60_000,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
// Work around SDK publishing issue where exports point to src/ instead of dist/
|
||||
"@opencode-ai/sdk": resolveSdkPath(),
|
||||
},
|
||||
},
|
||||
});
|
||||
73
server/packages/sandbox-agent/tests/opencode_openapi.rs
Normal file
73
server/packages/sandbox-agent/tests/opencode_openapi.rs
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
use std::collections::BTreeSet;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use sandbox_agent::opencode_compat::OpenCodeApiDoc;
|
||||
use serde_json::Value;
|
||||
use utoipa::OpenApi;
|
||||
|
||||
fn collect_path_methods(spec: &Value) -> BTreeSet<String> {
|
||||
let mut methods = BTreeSet::new();
|
||||
let Some(paths) = spec.get("paths").and_then(|value| value.as_object()) else {
|
||||
return methods;
|
||||
};
|
||||
for (path, item) in paths {
|
||||
let Some(item) = item.as_object() else {
|
||||
continue;
|
||||
};
|
||||
for method in [
|
||||
"get", "post", "put", "patch", "delete", "options", "head", "trace",
|
||||
] {
|
||||
if item.contains_key(method) {
|
||||
methods.insert(format!("{} {}", method.to_uppercase(), path));
|
||||
}
|
||||
}
|
||||
}
|
||||
methods
|
||||
}
|
||||
|
||||
fn official_spec_path() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../../resources/agent-schemas/artifacts/openapi/opencode.json")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opencode_openapi_matches_official_paths() {
|
||||
let official_path = official_spec_path();
|
||||
let official_json = fs::read_to_string(&official_path)
|
||||
.unwrap_or_else(|err| panic!("failed to read official OpenCode spec at {official_path:?}: {err}"));
|
||||
let official: Value =
|
||||
serde_json::from_str(&official_json).expect("official OpenCode spec is not valid JSON");
|
||||
|
||||
let ours = OpenCodeApiDoc::openapi();
|
||||
let ours_value = serde_json::to_value(&ours).expect("failed to serialize OpenCode OpenAPI");
|
||||
|
||||
let official_methods = collect_path_methods(&official);
|
||||
let our_methods = collect_path_methods(&ours_value);
|
||||
|
||||
let missing: Vec<_> = official_methods
|
||||
.difference(&our_methods)
|
||||
.cloned()
|
||||
.collect();
|
||||
let extra: Vec<_> = our_methods
|
||||
.difference(&official_methods)
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
if !missing.is_empty() || !extra.is_empty() {
|
||||
let mut message = String::new();
|
||||
if !missing.is_empty() {
|
||||
message.push_str("Missing endpoints (present in official spec, absent in ours):\n");
|
||||
for endpoint in &missing {
|
||||
message.push_str(&format!("- {endpoint}\n"));
|
||||
}
|
||||
}
|
||||
if !extra.is_empty() {
|
||||
message.push_str("Extra endpoints (present in ours, absent in official spec):\n");
|
||||
for endpoint in &extra {
|
||||
message.push_str(&format!("- {endpoint}\n"));
|
||||
}
|
||||
}
|
||||
panic!("{message}");
|
||||
}
|
||||
}
|
||||
|
|
@ -43,7 +43,7 @@ pub fn event_to_universal(event: &schema::Event) -> Result<Vec<EventConversion>,
|
|||
let (session_id, message_id) = part_session_message(part);
|
||||
|
||||
match part {
|
||||
schema::Part::Variant0(text_part) => {
|
||||
schema::Part::TextPart(text_part) => {
|
||||
let schema::TextPart { text, .. } = text_part;
|
||||
let delta_text = delta.as_ref().unwrap_or(&text).clone();
|
||||
let stub = stub_message_item(&message_id, ItemRole::Assistant);
|
||||
|
|
@ -68,7 +68,7 @@ pub fn event_to_universal(event: &schema::Event) -> Result<Vec<EventConversion>,
|
|||
.with_raw(raw.clone()),
|
||||
);
|
||||
}
|
||||
schema::Part::Variant2(reasoning_part) => {
|
||||
schema::Part::ReasoningPart(reasoning_part) => {
|
||||
let delta_text = delta
|
||||
.as_ref()
|
||||
.cloned()
|
||||
|
|
@ -95,7 +95,7 @@ pub fn event_to_universal(event: &schema::Event) -> Result<Vec<EventConversion>,
|
|||
.with_raw(raw.clone()),
|
||||
);
|
||||
}
|
||||
schema::Part::Variant3(file_part) => {
|
||||
schema::Part::FilePart(file_part) => {
|
||||
let file_content = file_part_to_content(file_part);
|
||||
let item = UniversalItem {
|
||||
item_id: String::new(),
|
||||
|
|
@ -115,7 +115,7 @@ pub fn event_to_universal(event: &schema::Event) -> Result<Vec<EventConversion>,
|
|||
.with_raw(raw.clone()),
|
||||
);
|
||||
}
|
||||
schema::Part::Variant4(tool_part) => {
|
||||
schema::Part::ToolPart(tool_part) => {
|
||||
let tool_events = tool_part_to_events(&tool_part, &message_id);
|
||||
for event in tool_events {
|
||||
events.push(
|
||||
|
|
@ -125,9 +125,9 @@ pub fn event_to_universal(event: &schema::Event) -> Result<Vec<EventConversion>,
|
|||
);
|
||||
}
|
||||
}
|
||||
schema::Part::Variant1 { .. } => {
|
||||
let detail =
|
||||
serde_json::to_string(part).unwrap_or_else(|_| "subtask".to_string());
|
||||
schema::Part::SubtaskPart(subtask_part) => {
|
||||
let detail = serde_json::to_string(subtask_part)
|
||||
.unwrap_or_else(|_| "subtask".to_string());
|
||||
let item = status_item("subtask", Some(detail));
|
||||
events.push(
|
||||
EventConversion::new(
|
||||
|
|
@ -138,13 +138,13 @@ pub fn event_to_universal(event: &schema::Event) -> Result<Vec<EventConversion>,
|
|||
.with_raw(raw.clone()),
|
||||
);
|
||||
}
|
||||
schema::Part::Variant5(_)
|
||||
| schema::Part::Variant6(_)
|
||||
| schema::Part::Variant7(_)
|
||||
| schema::Part::Variant8(_)
|
||||
| schema::Part::Variant9(_)
|
||||
| schema::Part::Variant10(_)
|
||||
| schema::Part::Variant11(_) => {
|
||||
schema::Part::StepStartPart(_)
|
||||
| schema::Part::StepFinishPart(_)
|
||||
| schema::Part::SnapshotPart(_)
|
||||
| schema::Part::PatchPart(_)
|
||||
| schema::Part::AgentPart(_)
|
||||
| schema::Part::RetryPart(_)
|
||||
| schema::Part::CompactionPart(_) => {
|
||||
let detail = serde_json::to_string(part).unwrap_or_else(|_| "part".to_string());
|
||||
let item = status_item("part.updated", Some(detail));
|
||||
events.push(
|
||||
|
|
@ -306,52 +306,51 @@ fn message_to_item(message: &schema::Message) -> (UniversalItem, bool, Option<St
|
|||
|
||||
fn part_session_message(part: &schema::Part) -> (Option<String>, String) {
|
||||
match part {
|
||||
schema::Part::Variant0(text_part) => (
|
||||
schema::Part::TextPart(text_part) => (
|
||||
Some(text_part.session_id.clone()),
|
||||
text_part.message_id.clone(),
|
||||
),
|
||||
schema::Part::Variant1 {
|
||||
session_id,
|
||||
message_id,
|
||||
..
|
||||
} => (Some(session_id.clone()), message_id.clone()),
|
||||
schema::Part::Variant2(reasoning_part) => (
|
||||
schema::Part::SubtaskPart(subtask_part) => (
|
||||
Some(subtask_part.session_id.clone()),
|
||||
subtask_part.message_id.clone(),
|
||||
),
|
||||
schema::Part::ReasoningPart(reasoning_part) => (
|
||||
Some(reasoning_part.session_id.clone()),
|
||||
reasoning_part.message_id.clone(),
|
||||
),
|
||||
schema::Part::Variant3(file_part) => (
|
||||
schema::Part::FilePart(file_part) => (
|
||||
Some(file_part.session_id.clone()),
|
||||
file_part.message_id.clone(),
|
||||
),
|
||||
schema::Part::Variant4(tool_part) => (
|
||||
schema::Part::ToolPart(tool_part) => (
|
||||
Some(tool_part.session_id.clone()),
|
||||
tool_part.message_id.clone(),
|
||||
),
|
||||
schema::Part::Variant5(step_part) => (
|
||||
schema::Part::StepStartPart(step_part) => (
|
||||
Some(step_part.session_id.clone()),
|
||||
step_part.message_id.clone(),
|
||||
),
|
||||
schema::Part::Variant6(step_part) => (
|
||||
schema::Part::StepFinishPart(step_part) => (
|
||||
Some(step_part.session_id.clone()),
|
||||
step_part.message_id.clone(),
|
||||
),
|
||||
schema::Part::Variant7(snapshot_part) => (
|
||||
schema::Part::SnapshotPart(snapshot_part) => (
|
||||
Some(snapshot_part.session_id.clone()),
|
||||
snapshot_part.message_id.clone(),
|
||||
),
|
||||
schema::Part::Variant8(patch_part) => (
|
||||
schema::Part::PatchPart(patch_part) => (
|
||||
Some(patch_part.session_id.clone()),
|
||||
patch_part.message_id.clone(),
|
||||
),
|
||||
schema::Part::Variant9(agent_part) => (
|
||||
schema::Part::AgentPart(agent_part) => (
|
||||
Some(agent_part.session_id.clone()),
|
||||
agent_part.message_id.clone(),
|
||||
),
|
||||
schema::Part::Variant10(retry_part) => (
|
||||
schema::Part::RetryPart(retry_part) => (
|
||||
Some(retry_part.session_id.clone()),
|
||||
retry_part.message_id.clone(),
|
||||
),
|
||||
schema::Part::Variant11(compaction_part) => (
|
||||
schema::Part::CompactionPart(compaction_part) => (
|
||||
Some(compaction_part.session_id.clone()),
|
||||
compaction_part.message_id.clone(),
|
||||
),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue