mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 05:02:11 +00:00
chore: fix bad merge
This commit is contained in:
parent
1dd45908a3
commit
94353f7696
205 changed files with 19244 additions and 14866 deletions
|
|
@ -313,7 +313,7 @@ const client = await SandboxAgent.connect({
|
|||
|
||||
### Auto-Detection
|
||||
|
||||
`SandboxAgent` provides two factory methods:
|
||||
Sandbox Agent provides two factory methods:
|
||||
|
||||
```typescript
|
||||
// Connect to existing server
|
||||
|
|
|
|||
24
server/packages/acp-http-adapter/Cargo.toml
Normal file
24
server/packages/acp-http-adapter/Cargo.toml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
[package]
|
||||
name = "acp-http-adapter"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Minimal ACP HTTP-to-stdio adapter"
|
||||
|
||||
[dependencies]
|
||||
axum.workspace = true
|
||||
tokio = { workspace = true, features = ["process", "io-util"] }
|
||||
tokio-stream.workspace = true
|
||||
futures.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
clap.workspace = true
|
||||
thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
reqwest.workspace = true
|
||||
bytes = "1.10"
|
||||
47
server/packages/acp-http-adapter/README.md
Normal file
47
server/packages/acp-http-adapter/README.md
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# acp-http-adapter
|
||||
|
||||
Minimal ACP HTTP to stdio proxy.
|
||||
|
||||
## Endpoints
|
||||
|
||||
- `GET /v1/health`
|
||||
- `POST /v1/rpc`
|
||||
- `GET /v1/rpc` (SSE)
|
||||
- `DELETE /v1/rpc`
|
||||
|
||||
## Stdio framing
|
||||
|
||||
Uses ACP stdio framing from ACP docs:
|
||||
- UTF-8 JSON-RPC messages
|
||||
- one message per line
|
||||
- newline-delimited (`\n`)
|
||||
- no embedded newlines in messages
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
cargo run -p acp-http-adapter -- \
|
||||
--host 127.0.0.1 \
|
||||
--port 7591 \
|
||||
--registry-json '{"distribution":{"npx":{"package":"@zed-industries/codex-acp"}}}'
|
||||
```
|
||||
|
||||
`--registry-json` accepts:
|
||||
- full registry document (`{"agents":[...]}`) with `--registry-agent-id`
|
||||
- single registry entry (`{"id":"...","distribution":...}`)
|
||||
- direct distribution object (`{"npx":...}` or `{"binary":...}`)
|
||||
|
||||
## Library
|
||||
|
||||
```rust
|
||||
use std::time::Duration;
|
||||
use acp_http_adapter::{run_server, ServerConfig};
|
||||
|
||||
run_server(ServerConfig {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 7591,
|
||||
registry_json: r#"{"distribution":{"npx":{"package":"@zed-industries/codex-acp"}}}"#.to_string(),
|
||||
registry_agent_id: None,
|
||||
rpc_timeout: Duration::from_secs(120),
|
||||
}).await?;
|
||||
```
|
||||
132
server/packages/acp-http-adapter/src/app.rs
Normal file
132
server/packages/acp-http-adapter/src/app.rs
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::http::{header, HeaderMap, StatusCode};
|
||||
use axum::response::sse::KeepAlive;
|
||||
use axum::response::{IntoResponse, Response, Sse};
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::process::{AdapterError, AdapterRuntime, PostOutcome};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct HealthResponse {
|
||||
ok: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct Problem {
|
||||
r#type: &'static str,
|
||||
title: &'static str,
|
||||
status: u16,
|
||||
detail: String,
|
||||
}
|
||||
|
||||
pub fn build_router(runtime: Arc<AdapterRuntime>) -> Router {
|
||||
Router::new()
|
||||
.route("/v1/health", get(get_health))
|
||||
.route("/v1/rpc", post(post_rpc).get(get_rpc).delete(delete_rpc))
|
||||
.with_state(runtime)
|
||||
}
|
||||
|
||||
async fn get_health() -> Json<HealthResponse> {
|
||||
Json(HealthResponse { ok: true })
|
||||
}
|
||||
|
||||
async fn post_rpc(
|
||||
State(runtime): State<Arc<AdapterRuntime>>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<Value>,
|
||||
) -> Response {
|
||||
if !is_json_content_type(&headers) {
|
||||
return problem(
|
||||
StatusCode::UNSUPPORTED_MEDIA_TYPE,
|
||||
"unsupported_media_type",
|
||||
"content-type must be application/json",
|
||||
);
|
||||
}
|
||||
|
||||
match runtime.post(payload).await {
|
||||
Ok(PostOutcome::Response(value)) => (StatusCode::OK, Json(value)).into_response(),
|
||||
Ok(PostOutcome::Accepted) => StatusCode::ACCEPTED.into_response(),
|
||||
Err(err) => map_error(err),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_rpc(
|
||||
State(runtime): State<Arc<AdapterRuntime>>,
|
||||
headers: HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
let last_event_id = headers
|
||||
.get("last-event-id")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.parse::<u64>().ok());
|
||||
|
||||
let stream = runtime.clone().sse_stream(last_event_id).await;
|
||||
Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))
|
||||
}
|
||||
|
||||
async fn delete_rpc() -> StatusCode {
|
||||
StatusCode::NO_CONTENT
|
||||
}
|
||||
|
||||
fn is_json_content_type(headers: &HeaderMap) -> bool {
|
||||
headers
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(|value| value.starts_with("application/json"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn map_error(err: AdapterError) -> Response {
|
||||
match err {
|
||||
AdapterError::InvalidEnvelope => problem(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_envelope",
|
||||
"request body must be a JSON-RPC object",
|
||||
),
|
||||
AdapterError::Timeout => problem(
|
||||
StatusCode::GATEWAY_TIMEOUT,
|
||||
"timeout",
|
||||
"timed out waiting for agent response",
|
||||
),
|
||||
AdapterError::Write(write) => problem(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"write_failed",
|
||||
&format!("failed writing to agent stdin: {write}"),
|
||||
),
|
||||
AdapterError::Serialize(ser) => problem(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"serialize_failed",
|
||||
&format!("failed to serialize JSON payload: {ser}"),
|
||||
),
|
||||
AdapterError::Spawn(spawn) => problem(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"spawn_failed",
|
||||
&format!("failed to start agent process: {spawn}"),
|
||||
),
|
||||
AdapterError::MissingStdin | AdapterError::MissingStdout | AdapterError::MissingStderr => {
|
||||
problem(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"io_setup_failed",
|
||||
"agent subprocess pipes were not available",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn problem(status: StatusCode, title: &'static str, detail: &str) -> Response {
|
||||
(
|
||||
status,
|
||||
Json(json!(Problem {
|
||||
r#type: "about:blank",
|
||||
title,
|
||||
status: status.as_u16(),
|
||||
detail: detail.to_string(),
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
50
server/packages/acp-http-adapter/src/lib.rs
Normal file
50
server/packages/acp-http-adapter/src/lib.rs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use app::build_router;
|
||||
use process::AdapterRuntime;
|
||||
use registry::LaunchSpec;
|
||||
|
||||
pub mod app;
|
||||
pub mod process;
|
||||
pub mod registry;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServerConfig {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub registry_json: String,
|
||||
pub registry_agent_id: Option<String>,
|
||||
pub rpc_timeout: Duration,
|
||||
}
|
||||
|
||||
pub async fn run_server(
|
||||
config: ServerConfig,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let launch =
|
||||
LaunchSpec::from_registry_blob(&config.registry_json, config.registry_agent_id.as_deref())?;
|
||||
let runtime = Arc::new(AdapterRuntime::start(launch, config.rpc_timeout).await?);
|
||||
run_server_with_runtime(config.host, config.port, runtime).await
|
||||
}
|
||||
|
||||
pub async fn run_server_with_runtime(
|
||||
host: String,
|
||||
port: u16,
|
||||
runtime: Arc<AdapterRuntime>,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let app = build_router(runtime.clone());
|
||||
let addr: SocketAddr = format!("{host}:{port}").parse()?;
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
tracing::info!(addr = %addr, "acp-http-adapter listening");
|
||||
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(shutdown_signal(runtime))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn shutdown_signal(runtime: Arc<AdapterRuntime>) {
|
||||
let _ = tokio::signal::ctrl_c().await;
|
||||
runtime.shutdown().await;
|
||||
}
|
||||
55
server/packages/acp-http-adapter/src/main.rs
Normal file
55
server/packages/acp-http-adapter/src/main.rs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use acp_http_adapter::{run_server, ServerConfig};
|
||||
use clap::Parser;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(name = "acp-http-adapter")]
|
||||
#[command(about = "Minimal ACP HTTP->stdio adapter", version)]
|
||||
struct Cli {
|
||||
#[arg(long, default_value = "127.0.0.1")]
|
||||
host: String,
|
||||
|
||||
#[arg(long, default_value_t = 7591)]
|
||||
port: u16,
|
||||
|
||||
#[arg(long)]
|
||||
registry_json: String,
|
||||
|
||||
#[arg(long)]
|
||||
registry_agent_id: Option<String>,
|
||||
|
||||
#[arg(long)]
|
||||
rpc_timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
if let Err(err) = run().await {
|
||||
tracing::error!(error = %err, "acp-http-adapter failed");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async fn run() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||
)
|
||||
.compact()
|
||||
.init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
run_server(ServerConfig {
|
||||
host: cli.host,
|
||||
port: cli.port,
|
||||
registry_json: cli.registry_json,
|
||||
registry_agent_id: cli.registry_agent_id,
|
||||
rpc_timeout: cli
|
||||
.rpc_timeout_ms
|
||||
.map(Duration::from_millis)
|
||||
.unwrap_or_else(|| Duration::from_secs(120)),
|
||||
})
|
||||
.await
|
||||
}
|
||||
567
server/packages/acp-http-adapter/src/process.rs
Normal file
567
server/packages/acp-http-adapter/src/process.rs
Normal file
|
|
@ -0,0 +1,567 @@
|
|||
use std::collections::{HashMap, VecDeque};
|
||||
use std::convert::Infallible;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use axum::response::sse::Event;
|
||||
use futures::{stream, Stream, StreamExt};
|
||||
use serde_json::{json, Value};
|
||||
use thiserror::Error;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::process::{Child, ChildStdin, Command};
|
||||
use tokio::sync::{broadcast, oneshot, Mutex};
|
||||
use tokio_stream::wrappers::BroadcastStream;
|
||||
|
||||
use crate::registry::LaunchSpec;
|
||||
|
||||
const RING_BUFFER_SIZE: usize = 1024;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AdapterError {
|
||||
#[error("failed to spawn subprocess: {0}")]
|
||||
Spawn(std::io::Error),
|
||||
#[error("failed to capture subprocess stdin")]
|
||||
MissingStdin,
|
||||
#[error("failed to capture subprocess stdout")]
|
||||
MissingStdout,
|
||||
#[error("failed to capture subprocess stderr")]
|
||||
MissingStderr,
|
||||
#[error("invalid json-rpc envelope")]
|
||||
InvalidEnvelope,
|
||||
#[error("failed to serialize json-rpc message: {0}")]
|
||||
Serialize(serde_json::Error),
|
||||
#[error("failed to write subprocess stdin: {0}")]
|
||||
Write(std::io::Error),
|
||||
#[error("timeout waiting for response")]
|
||||
Timeout,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum PostOutcome {
|
||||
Response(Value),
|
||||
Accepted,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct StreamMessage {
|
||||
sequence: u64,
|
||||
payload: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AdapterRuntime {
|
||||
stdin: Arc<Mutex<ChildStdin>>,
|
||||
child: Arc<Mutex<Child>>,
|
||||
pending: Arc<Mutex<HashMap<String, oneshot::Sender<Value>>>>,
|
||||
sender: broadcast::Sender<StreamMessage>,
|
||||
ring: Arc<Mutex<VecDeque<StreamMessage>>>,
|
||||
sequence: Arc<AtomicU64>,
|
||||
request_timeout: Duration,
|
||||
shutting_down: AtomicBool,
|
||||
spawned_at: Instant,
|
||||
first_stdout: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl AdapterRuntime {
|
||||
pub async fn start(
|
||||
launch: LaunchSpec,
|
||||
request_timeout: Duration,
|
||||
) -> Result<Self, AdapterError> {
|
||||
let spawn_start = Instant::now();
|
||||
|
||||
let mut command = Command::new(&launch.program);
|
||||
command
|
||||
.args(&launch.args)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped());
|
||||
|
||||
for (key, value) in &launch.env {
|
||||
command.env(key, value);
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
program = ?launch.program,
|
||||
args = ?launch.args,
|
||||
"spawning agent process"
|
||||
);
|
||||
|
||||
let mut child = command.spawn().map_err(|err| {
|
||||
tracing::error!(
|
||||
program = ?launch.program,
|
||||
error = %err,
|
||||
"failed to spawn agent process"
|
||||
);
|
||||
AdapterError::Spawn(err)
|
||||
})?;
|
||||
|
||||
let pid = child.id().unwrap_or(0);
|
||||
let spawn_elapsed = spawn_start.elapsed();
|
||||
tracing::info!(
|
||||
pid = pid,
|
||||
elapsed_ms = spawn_elapsed.as_millis() as u64,
|
||||
"agent process spawned"
|
||||
);
|
||||
|
||||
let stdin = child.stdin.take().ok_or(AdapterError::MissingStdin)?;
|
||||
let stdout = child.stdout.take().ok_or(AdapterError::MissingStdout)?;
|
||||
let stderr = child.stderr.take().ok_or(AdapterError::MissingStderr)?;
|
||||
|
||||
let (sender, _rx) = broadcast::channel(512);
|
||||
let runtime = Self {
|
||||
stdin: Arc::new(Mutex::new(stdin)),
|
||||
child: Arc::new(Mutex::new(child)),
|
||||
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||
sender,
|
||||
ring: Arc::new(Mutex::new(VecDeque::with_capacity(RING_BUFFER_SIZE))),
|
||||
sequence: Arc::new(AtomicU64::new(0)),
|
||||
request_timeout,
|
||||
shutting_down: AtomicBool::new(false),
|
||||
spawned_at: spawn_start,
|
||||
first_stdout: Arc::new(AtomicBool::new(false)),
|
||||
};
|
||||
|
||||
runtime.spawn_stdout_loop(stdout);
|
||||
runtime.spawn_stderr_loop(stderr);
|
||||
runtime.spawn_exit_watcher();
|
||||
|
||||
Ok(runtime)
|
||||
}
|
||||
|
||||
pub async fn post(&self, payload: Value) -> Result<PostOutcome, AdapterError> {
|
||||
if !payload.is_object() {
|
||||
return Err(AdapterError::InvalidEnvelope);
|
||||
}
|
||||
|
||||
let method: String = payload
|
||||
.get("method")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("<none>")
|
||||
.to_string();
|
||||
let has_method = payload.get("method").is_some();
|
||||
let id = payload.get("id");
|
||||
|
||||
if has_method && id.is_some() {
|
||||
let id_value = id.expect("checked");
|
||||
let key = id_key(id_value);
|
||||
let (tx, rx) = oneshot::channel();
|
||||
|
||||
let pending_count = self.pending.lock().await.len();
|
||||
tracing::info!(
|
||||
method = %method,
|
||||
id = %key,
|
||||
pending_count = pending_count,
|
||||
"post: request → agent (awaiting response)"
|
||||
);
|
||||
|
||||
self.pending.lock().await.insert(key.clone(), tx);
|
||||
|
||||
let write_start = Instant::now();
|
||||
if let Err(err) = self.send_to_subprocess(&payload).await {
|
||||
tracing::error!(
|
||||
method = %method,
|
||||
id = %key,
|
||||
error = %err,
|
||||
"post: failed to write to agent stdin"
|
||||
);
|
||||
self.pending.lock().await.remove(&key);
|
||||
return Err(err);
|
||||
}
|
||||
let write_ms = write_start.elapsed().as_millis() as u64;
|
||||
tracing::debug!(
|
||||
method = %method,
|
||||
id = %key,
|
||||
write_ms = write_ms,
|
||||
"post: stdin write complete, waiting for response"
|
||||
);
|
||||
|
||||
let wait_start = Instant::now();
|
||||
match tokio::time::timeout(self.request_timeout, rx).await {
|
||||
Ok(Ok(response)) => {
|
||||
let wait_ms = wait_start.elapsed().as_millis() as u64;
|
||||
tracing::info!(
|
||||
method = %method,
|
||||
id = %key,
|
||||
response_ms = wait_ms,
|
||||
total_ms = write_ms + wait_ms,
|
||||
"post: got response from agent"
|
||||
);
|
||||
Ok(PostOutcome::Response(response))
|
||||
}
|
||||
Ok(Err(_)) => {
|
||||
let wait_ms = wait_start.elapsed().as_millis() as u64;
|
||||
tracing::error!(
|
||||
method = %method,
|
||||
id = %key,
|
||||
wait_ms = wait_ms,
|
||||
"post: response channel dropped (agent process may have exited)"
|
||||
);
|
||||
self.pending.lock().await.remove(&key);
|
||||
Err(AdapterError::Timeout)
|
||||
}
|
||||
Err(_) => {
|
||||
let pending_keys: Vec<String> =
|
||||
self.pending.lock().await.keys().cloned().collect();
|
||||
tracing::error!(
|
||||
method = %method,
|
||||
id = %key,
|
||||
timeout_ms = self.request_timeout.as_millis() as u64,
|
||||
age_ms = self.spawned_at.elapsed().as_millis() as u64,
|
||||
pending_keys = ?pending_keys,
|
||||
first_stdout_seen = self.first_stdout.load(Ordering::Relaxed),
|
||||
"post: TIMEOUT waiting for agent response"
|
||||
);
|
||||
self.pending.lock().await.remove(&key);
|
||||
Err(AdapterError::Timeout)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::debug!(
|
||||
method = %method,
|
||||
"post: notification → agent (fire-and-forget)"
|
||||
);
|
||||
self.send_to_subprocess(&payload).await?;
|
||||
Ok(PostOutcome::Accepted)
|
||||
}
|
||||
}
|
||||
|
||||
async fn subscribe(
|
||||
&self,
|
||||
last_event_id: Option<u64>,
|
||||
) -> (Vec<(u64, Value)>, broadcast::Receiver<StreamMessage>) {
|
||||
let replay = {
|
||||
let ring = self.ring.lock().await;
|
||||
ring.iter()
|
||||
.filter(|message| {
|
||||
if let Some(last_event_id) = last_event_id {
|
||||
message.sequence > last_event_id
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
.map(|message| (message.sequence, message.payload.clone()))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
(replay, self.sender.subscribe())
|
||||
}
|
||||
|
||||
pub async fn sse_stream(
|
||||
self: Arc<Self>,
|
||||
last_event_id: Option<u64>,
|
||||
) -> impl Stream<Item = Result<Event, Infallible>> + Send + 'static {
|
||||
let (replay, rx) = self.subscribe(last_event_id).await;
|
||||
let replay_stream = stream::iter(replay.into_iter().map(|(sequence, payload)| {
|
||||
let event = Event::default()
|
||||
.event("message")
|
||||
.id(sequence.to_string())
|
||||
.data(payload.to_string());
|
||||
Ok(event)
|
||||
}));
|
||||
|
||||
let live_stream = BroadcastStream::new(rx).filter_map(|item| async move {
|
||||
match item {
|
||||
Ok(message) => {
|
||||
let event = Event::default()
|
||||
.event("message")
|
||||
.id(message.sequence.to_string())
|
||||
.data(message.payload.to_string());
|
||||
Some(Ok(event))
|
||||
}
|
||||
Err(_) => None,
|
||||
}
|
||||
});
|
||||
|
||||
replay_stream.chain(live_stream)
|
||||
}
|
||||
|
||||
/// Stream of raw JSON-RPC `Value` payloads (without SSE framing).
|
||||
/// Useful for consumers that need to inspect the payload contents
|
||||
/// rather than forward them as SSE events.
|
||||
pub async fn value_stream(
|
||||
self: Arc<Self>,
|
||||
last_event_id: Option<u64>,
|
||||
) -> impl Stream<Item = Value> + Send + 'static {
|
||||
let (replay, rx) = self.subscribe(last_event_id).await;
|
||||
let replay_stream = stream::iter(replay.into_iter().map(|(_sequence, payload)| payload));
|
||||
let live_stream = BroadcastStream::new(rx).filter_map(|item| async move {
|
||||
match item {
|
||||
Ok(message) => Some(message.payload),
|
||||
Err(_) => None,
|
||||
}
|
||||
});
|
||||
replay_stream.chain(live_stream)
|
||||
}
|
||||
|
||||
pub async fn shutdown(&self) {
|
||||
if self.shutting_down.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
age_ms = self.spawned_at.elapsed().as_millis() as u64,
|
||||
"shutting down agent process"
|
||||
);
|
||||
|
||||
self.pending.lock().await.clear();
|
||||
let mut child = self.child.lock().await;
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => {}
|
||||
Ok(None) => {
|
||||
let _ = child.kill().await;
|
||||
let _ = child.wait().await;
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = child.kill().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_stdout_loop(&self, stdout: tokio::process::ChildStdout) {
|
||||
let pending = self.pending.clone();
|
||||
let sender = self.sender.clone();
|
||||
let ring = self.ring.clone();
|
||||
let sequence = self.sequence.clone();
|
||||
let spawned_at = self.spawned_at;
|
||||
let first_stdout = self.first_stdout.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(stdout).lines();
|
||||
let mut line_count: u64 = 0;
|
||||
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
line_count += 1;
|
||||
|
||||
if !first_stdout.swap(true, Ordering::Relaxed) {
|
||||
tracing::info!(
|
||||
first_stdout_ms = spawned_at.elapsed().as_millis() as u64,
|
||||
line_bytes = trimmed.len(),
|
||||
"agent process: first stdout line received"
|
||||
);
|
||||
}
|
||||
|
||||
let payload = match serde_json::from_str::<Value>(trimmed) {
|
||||
Ok(payload) => payload,
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
error = %err,
|
||||
line_number = line_count,
|
||||
raw = %if trimmed.len() > 200 {
|
||||
format!("{}...", &trimmed[..200])
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
},
|
||||
"agent stdout: invalid JSON"
|
||||
);
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "_adapter/invalid_stdout",
|
||||
"params": {
|
||||
"error": err.to_string(),
|
||||
"raw": trimmed,
|
||||
}
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
let is_response = payload.get("id").is_some() && payload.get("method").is_none();
|
||||
if is_response {
|
||||
let key = id_key(payload.get("id").expect("checked"));
|
||||
let has_error = payload.get("error").is_some();
|
||||
if let Some(tx) = pending.lock().await.remove(&key) {
|
||||
tracing::debug!(
|
||||
id = %key,
|
||||
has_error = has_error,
|
||||
age_ms = spawned_at.elapsed().as_millis() as u64,
|
||||
"agent stdout: response matched to pending request"
|
||||
);
|
||||
let _ = tx.send(payload.clone());
|
||||
// Also broadcast the response so SSE/notification subscribers
|
||||
// see it in order after preceding notifications. This lets the
|
||||
// SSE translation task detect turn completion after all
|
||||
// session/update events have been processed.
|
||||
let seq = sequence.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
let message = StreamMessage {
|
||||
sequence: seq,
|
||||
payload,
|
||||
};
|
||||
{
|
||||
let mut guard = ring.lock().await;
|
||||
guard.push_back(message.clone());
|
||||
while guard.len() > RING_BUFFER_SIZE {
|
||||
guard.pop_front();
|
||||
}
|
||||
}
|
||||
let _ = sender.send(message);
|
||||
continue;
|
||||
} else {
|
||||
tracing::warn!(
|
||||
id = %key,
|
||||
has_error = has_error,
|
||||
"agent stdout: response has no matching pending request (orphan)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let method = payload
|
||||
.get("method")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("<none>");
|
||||
tracing::debug!(
|
||||
method = method,
|
||||
line_number = line_count,
|
||||
"agent stdout: notification/event → SSE broadcast"
|
||||
);
|
||||
|
||||
let seq = sequence.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
let message = StreamMessage {
|
||||
sequence: seq,
|
||||
payload,
|
||||
};
|
||||
|
||||
{
|
||||
let mut guard = ring.lock().await;
|
||||
guard.push_back(message.clone());
|
||||
while guard.len() > RING_BUFFER_SIZE {
|
||||
guard.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
let _ = sender.send(message);
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
total_lines = line_count,
|
||||
age_ms = spawned_at.elapsed().as_millis() as u64,
|
||||
"agent stdout: stream ended"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn spawn_stderr_loop(&self, stderr: tokio::process::ChildStderr) {
|
||||
let spawned_at = self.spawned_at;
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(stderr).lines();
|
||||
let mut line_count: u64 = 0;
|
||||
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
line_count += 1;
|
||||
tracing::info!(
|
||||
line_number = line_count,
|
||||
age_ms = spawned_at.elapsed().as_millis() as u64,
|
||||
"agent stderr: {}",
|
||||
line
|
||||
);
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
total_lines = line_count,
|
||||
age_ms = spawned_at.elapsed().as_millis() as u64,
|
||||
"agent stderr: stream ended"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn spawn_exit_watcher(&self) {
|
||||
let child = self.child.clone();
|
||||
let sender = self.sender.clone();
|
||||
let ring = self.ring.clone();
|
||||
let sequence = self.sequence.clone();
|
||||
let spawned_at = self.spawned_at;
|
||||
let pending = self.pending.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let status = {
|
||||
let mut guard = child.lock().await;
|
||||
guard.wait().await.ok()
|
||||
};
|
||||
|
||||
let age_ms = spawned_at.elapsed().as_millis() as u64;
|
||||
let pending_count = pending.lock().await.len();
|
||||
|
||||
if let Some(status) = status {
|
||||
tracing::warn!(
|
||||
success = status.success(),
|
||||
code = status.code(),
|
||||
age_ms = age_ms,
|
||||
pending_requests = pending_count,
|
||||
"agent process exited"
|
||||
);
|
||||
|
||||
let payload = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "_adapter/agent_exited",
|
||||
"params": {
|
||||
"success": status.success(),
|
||||
"code": status.code(),
|
||||
}
|
||||
});
|
||||
|
||||
let seq = sequence.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
let message = StreamMessage {
|
||||
sequence: seq,
|
||||
payload,
|
||||
};
|
||||
|
||||
{
|
||||
let mut guard = ring.lock().await;
|
||||
guard.push_back(message.clone());
|
||||
while guard.len() > RING_BUFFER_SIZE {
|
||||
guard.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
let _ = sender.send(message);
|
||||
} else {
|
||||
tracing::error!(
|
||||
age_ms = age_ms,
|
||||
pending_requests = pending_count,
|
||||
"agent process: failed to get exit status"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn send_to_subprocess(&self, payload: &Value) -> Result<(), AdapterError> {
|
||||
let method = payload
|
||||
.get("method")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("<none>");
|
||||
let id = payload.get("id").map(|v| v.to_string()).unwrap_or_default();
|
||||
|
||||
tracing::debug!(
|
||||
method = method,
|
||||
id = %id,
|
||||
bytes = serde_json::to_vec(payload).map(|b| b.len()).unwrap_or(0),
|
||||
"stdin: writing message to agent"
|
||||
);
|
||||
|
||||
let mut stdin = self.stdin.lock().await;
|
||||
let bytes = serde_json::to_vec(payload).map_err(AdapterError::Serialize)?;
|
||||
stdin.write_all(&bytes).await.map_err(|err| {
|
||||
tracing::error!(method = method, id = %id, error = %err, "stdin: write_all failed");
|
||||
AdapterError::Write(err)
|
||||
})?;
|
||||
stdin.write_all(b"\n").await.map_err(|err| {
|
||||
tracing::error!(method = method, id = %id, error = %err, "stdin: newline write failed");
|
||||
AdapterError::Write(err)
|
||||
})?;
|
||||
stdin.flush().await.map_err(|err| {
|
||||
tracing::error!(method = method, id = %id, error = %err, "stdin: flush failed");
|
||||
AdapterError::Write(err)
|
||||
})?;
|
||||
|
||||
tracing::debug!(method = method, id = %id, "stdin: write+flush complete");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn id_key(value: &Value) -> String {
|
||||
serde_json::to_string(value).unwrap_or_else(|_| "null".to_string())
|
||||
}
|
||||
143
server/packages/acp-http-adapter/src/registry.rs
Normal file
143
server/packages/acp-http-adapter/src/registry.rs
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LaunchSpec {
|
||||
pub program: PathBuf,
|
||||
pub args: Vec<String>,
|
||||
pub env: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum RegistryError {
|
||||
#[error("invalid registry json: {0}")]
|
||||
InvalidJson(#[from] serde_json::Error),
|
||||
#[error("unable to resolve registry entry from blob")]
|
||||
UnsupportedBlob,
|
||||
#[error("registry blob has agents[] but no --registry-agent-id was provided")]
|
||||
MissingAgentId,
|
||||
#[error("agent '{0}' was not found in registry blob")]
|
||||
AgentNotFound(String),
|
||||
#[error("registry entry has no supported launch target")]
|
||||
MissingLaunchTarget,
|
||||
#[error("platform '{0}' is not present in distribution.binary")]
|
||||
UnsupportedPlatform(String),
|
||||
}
|
||||
|
||||
impl LaunchSpec {
|
||||
pub fn from_registry_blob(blob: &str, agent_id: Option<&str>) -> Result<Self, RegistryError> {
|
||||
let value: Value = serde_json::from_str(blob)?;
|
||||
Self::from_registry_value(value, agent_id)
|
||||
}
|
||||
|
||||
fn from_registry_value(value: Value, agent_id: Option<&str>) -> Result<Self, RegistryError> {
|
||||
if value.get("agents").is_some() {
|
||||
let doc: RegistryDocument = serde_json::from_value(value)?;
|
||||
let wanted = agent_id.ok_or(RegistryError::MissingAgentId)?;
|
||||
let agent = doc
|
||||
.agents
|
||||
.into_iter()
|
||||
.find(|a| a.id == wanted)
|
||||
.ok_or_else(|| RegistryError::AgentNotFound(wanted.to_string()))?;
|
||||
return Self::from_distribution(agent.distribution);
|
||||
}
|
||||
|
||||
if value.get("distribution").is_some() {
|
||||
let entry: RegistryAgent = serde_json::from_value(value)?;
|
||||
return Self::from_distribution(entry.distribution);
|
||||
}
|
||||
|
||||
if value.get("npx").is_some() || value.get("binary").is_some() {
|
||||
let distribution: RegistryDistribution = serde_json::from_value(value)?;
|
||||
return Self::from_distribution(distribution);
|
||||
}
|
||||
|
||||
Err(RegistryError::UnsupportedBlob)
|
||||
}
|
||||
|
||||
fn from_distribution(distribution: RegistryDistribution) -> Result<Self, RegistryError> {
|
||||
if let Some(npx) = distribution.npx {
|
||||
let mut args = vec!["-y".to_string(), npx.package];
|
||||
args.extend(npx.args);
|
||||
return Ok(Self {
|
||||
program: PathBuf::from("npx"),
|
||||
args,
|
||||
env: npx.env,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(binary) = distribution.binary {
|
||||
let platform = platform_key().ok_or(RegistryError::UnsupportedPlatform(format!(
|
||||
"{}/{}",
|
||||
std::env::consts::OS,
|
||||
std::env::consts::ARCH
|
||||
)))?;
|
||||
let target = binary
|
||||
.get(platform)
|
||||
.ok_or_else(|| RegistryError::UnsupportedPlatform(platform.to_string()))?;
|
||||
return Ok(Self {
|
||||
program: PathBuf::from(&target.cmd),
|
||||
args: target.args.clone(),
|
||||
env: target.env.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
Err(RegistryError::MissingLaunchTarget)
|
||||
}
|
||||
}
|
||||
|
||||
fn platform_key() -> Option<&'static str> {
|
||||
match (std::env::consts::OS, std::env::consts::ARCH) {
|
||||
("linux", "x86_64") => Some("linux-x86_64"),
|
||||
("linux", "aarch64") => Some("linux-aarch64"),
|
||||
("macos", "x86_64") => Some("darwin-x86_64"),
|
||||
("macos", "aarch64") => Some("darwin-aarch64"),
|
||||
("windows", "x86_64") => Some("windows-x86_64"),
|
||||
("windows", "aarch64") => Some("windows-aarch64"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RegistryDocument {
|
||||
agents: Vec<RegistryAgent>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RegistryAgent {
|
||||
#[allow(dead_code)]
|
||||
id: String,
|
||||
distribution: RegistryDistribution,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RegistryDistribution {
|
||||
#[serde(default)]
|
||||
npx: Option<RegistryNpx>,
|
||||
#[serde(default)]
|
||||
binary: Option<HashMap<String, RegistryBinaryTarget>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RegistryNpx {
|
||||
package: String,
|
||||
#[serde(default)]
|
||||
args: Vec<String>,
|
||||
#[serde(default)]
|
||||
env: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RegistryBinaryTarget {
|
||||
#[allow(dead_code)]
|
||||
archive: Option<String>,
|
||||
cmd: String,
|
||||
#[serde(default)]
|
||||
args: Vec<String>,
|
||||
#[serde(default)]
|
||||
env: HashMap<String, String>,
|
||||
}
|
||||
338
server/packages/acp-http-adapter/tests/e2e.rs
Normal file
338
server/packages/acp-http-adapter/tests/e2e.rs
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
use std::io;
|
||||
use std::net::TcpListener;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use futures::StreamExt;
|
||||
use reqwest::{Client, StatusCode};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
struct AdapterHandle {
|
||||
child: Child,
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
impl Drop for AdapterHandle {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_and_request_response_round_trip() {
|
||||
let adapter = spawn_adapter().expect("spawn adapter");
|
||||
wait_for_health(&adapter.base_url)
|
||||
.await
|
||||
.expect("wait for health");
|
||||
|
||||
let client = Client::new();
|
||||
let health = client
|
||||
.get(format!("{}/v1/health", adapter.base_url))
|
||||
.send()
|
||||
.await
|
||||
.expect("health request");
|
||||
assert_eq!(health.status(), StatusCode::OK);
|
||||
let health_json: Value = health.json().await.expect("health json");
|
||||
assert_eq!(health_json["ok"], true);
|
||||
|
||||
let payload = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "mock/ping",
|
||||
"params": {
|
||||
"text": "hello"
|
||||
}
|
||||
});
|
||||
|
||||
let response = client
|
||||
.post(format!("{}/v1/rpc", adapter.base_url))
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.expect("post rpc");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let body: Value = response.json().await.expect("response json");
|
||||
assert_eq!(body["jsonrpc"], "2.0");
|
||||
assert_eq!(body["id"], 1);
|
||||
assert_eq!(body["result"]["echoed"]["method"], "mock/ping");
|
||||
assert_eq!(body["result"]["echoed"]["params"]["text"], "hello");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sse_request_and_client_response_flow() {
|
||||
let adapter = spawn_adapter().expect("spawn adapter");
|
||||
wait_for_health(&adapter.base_url)
|
||||
.await
|
||||
.expect("wait for health");
|
||||
|
||||
let client = Client::new();
|
||||
|
||||
let sse_response = client
|
||||
.get(format!("{}/v1/rpc", adapter.base_url))
|
||||
.header("accept", "text/event-stream")
|
||||
.send()
|
||||
.await
|
||||
.expect("open sse");
|
||||
assert_eq!(sse_response.status(), StatusCode::OK);
|
||||
|
||||
let mut sse = SseReader::new(sse_response);
|
||||
|
||||
let request = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 42,
|
||||
"method": "mock/ask_client",
|
||||
"params": {
|
||||
"need": "input"
|
||||
}
|
||||
});
|
||||
|
||||
let initial = client
|
||||
.post(format!("{}/v1/rpc", adapter.base_url))
|
||||
.json(&request)
|
||||
.send()
|
||||
.await
|
||||
.expect("post ask_client");
|
||||
assert_eq!(initial.status(), StatusCode::OK);
|
||||
|
||||
let initial_body: Value = initial.json().await.expect("initial body");
|
||||
assert_eq!(initial_body["id"], 42);
|
||||
|
||||
let mut agent_request_id = None;
|
||||
for _ in 0..10 {
|
||||
let event = sse
|
||||
.next_json(Duration::from_secs(3))
|
||||
.await
|
||||
.expect("sse event");
|
||||
if event["method"] == "mock/request" {
|
||||
agent_request_id = event.get("id").cloned();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let agent_request_id = agent_request_id.expect("agent request id");
|
||||
|
||||
let client_response = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": agent_request_id,
|
||||
"result": {
|
||||
"approved": true
|
||||
}
|
||||
});
|
||||
|
||||
let response = client
|
||||
.post(format!("{}/v1/rpc", adapter.base_url))
|
||||
.json(&client_response)
|
||||
.send()
|
||||
.await
|
||||
.expect("post client response");
|
||||
assert_eq!(response.status(), StatusCode::ACCEPTED);
|
||||
|
||||
let mut saw_client_response = false;
|
||||
for _ in 0..10 {
|
||||
let event = sse
|
||||
.next_json(Duration::from_secs(3))
|
||||
.await
|
||||
.expect("sse follow-up");
|
||||
if event["method"] == "mock/client_response" {
|
||||
assert_eq!(event["params"]["result"]["approved"], true);
|
||||
saw_client_response = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
saw_client_response,
|
||||
"expected mock/client_response over SSE"
|
||||
);
|
||||
}
|
||||
|
||||
struct SseReader {
|
||||
stream: futures::stream::BoxStream<'static, Result<bytes::Bytes, reqwest::Error>>,
|
||||
buffer: Vec<u8>,
|
||||
}
|
||||
|
||||
impl SseReader {
|
||||
fn new(response: reqwest::Response) -> Self {
|
||||
Self {
|
||||
stream: response.bytes_stream().boxed(),
|
||||
buffer: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn next_json(&mut self, timeout: Duration) -> io::Result<Value> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
|
||||
loop {
|
||||
if let Some(event) = self.try_parse_event()? {
|
||||
return Ok(event);
|
||||
}
|
||||
|
||||
if Instant::now() >= deadline {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::TimedOut,
|
||||
"timed out waiting for sse event",
|
||||
));
|
||||
}
|
||||
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
let chunk = tokio::time::timeout(remaining, self.stream.next())
|
||||
.await
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "timed out reading sse"))?;
|
||||
|
||||
match chunk {
|
||||
Some(Ok(bytes)) => self.buffer.extend_from_slice(&bytes),
|
||||
Some(Err(err)) => {
|
||||
return Err(io::Error::other(format!("sse stream error: {err}")));
|
||||
}
|
||||
None => {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::UnexpectedEof,
|
||||
"sse stream ended",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn try_parse_event(&mut self) -> io::Result<Option<Value>> {
|
||||
let split = self
|
||||
.buffer
|
||||
.windows(2)
|
||||
.position(|window| window == b"\n\n")
|
||||
.or_else(|| {
|
||||
self.buffer
|
||||
.windows(4)
|
||||
.position(|window| window == b"\r\n\r\n")
|
||||
});
|
||||
|
||||
let Some(idx) = split else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let delimiter_len = if self.buffer.get(idx..idx + 2) == Some(b"\n\n") {
|
||||
2
|
||||
} else {
|
||||
4
|
||||
};
|
||||
|
||||
let block = self.buffer.drain(..idx + delimiter_len).collect::<Vec<_>>();
|
||||
let text = String::from_utf8_lossy(&block);
|
||||
|
||||
let data = text
|
||||
.lines()
|
||||
.filter_map(|line| line.strip_prefix("data: "))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
if data.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let value: Value = serde_json::from_str(&data).map_err(|err| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("invalid sse json payload: {err}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Some(value))
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_adapter() -> io::Result<AdapterHandle> {
|
||||
let port = pick_port()?;
|
||||
let base_url = format!("http://127.0.0.1:{port}");
|
||||
|
||||
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let workspace_root = manifest_dir
|
||||
.ancestors()
|
||||
.nth(3)
|
||||
.expect("workspace root")
|
||||
.to_path_buf();
|
||||
let mock_agent_js = workspace_root.join("examples/mock-acp-agent/dist/index.js");
|
||||
|
||||
let registry_blob = json!({
|
||||
"id": "mock-acp-agent",
|
||||
"distribution": {
|
||||
"binary": {
|
||||
"linux-x86_64": {
|
||||
"cmd": "node",
|
||||
"args": [mock_agent_js.to_string_lossy()],
|
||||
"env": {}
|
||||
},
|
||||
"linux-aarch64": {
|
||||
"cmd": "node",
|
||||
"args": [mock_agent_js.to_string_lossy()],
|
||||
"env": {}
|
||||
},
|
||||
"darwin-x86_64": {
|
||||
"cmd": "node",
|
||||
"args": [mock_agent_js.to_string_lossy()],
|
||||
"env": {}
|
||||
},
|
||||
"darwin-aarch64": {
|
||||
"cmd": "node",
|
||||
"args": [mock_agent_js.to_string_lossy()],
|
||||
"env": {}
|
||||
},
|
||||
"windows-x86_64": {
|
||||
"cmd": "node",
|
||||
"args": [mock_agent_js.to_string_lossy()],
|
||||
"env": {}
|
||||
},
|
||||
"windows-aarch64": {
|
||||
"cmd": "node",
|
||||
"args": [mock_agent_js.to_string_lossy()],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let child = Command::new(env!("CARGO_BIN_EXE_acp-http-adapter"))
|
||||
.arg("--host")
|
||||
.arg("127.0.0.1")
|
||||
.arg("--port")
|
||||
.arg(port.to_string())
|
||||
.arg("--registry-json")
|
||||
.arg(registry_blob)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()?;
|
||||
|
||||
Ok(AdapterHandle { child, base_url })
|
||||
}
|
||||
|
||||
fn pick_port() -> io::Result<u16> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")?;
|
||||
let port = listener.local_addr()?.port();
|
||||
drop(listener);
|
||||
Ok(port)
|
||||
}
|
||||
|
||||
async fn wait_for_health(base_url: &str) -> io::Result<()> {
|
||||
let client = Client::new();
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
|
||||
loop {
|
||||
if Instant::now() > deadline {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::TimedOut,
|
||||
"adapter did not become healthy",
|
||||
));
|
||||
}
|
||||
|
||||
if let Ok(response) = client.get(format!("{base_url}/v1/health")).send().await {
|
||||
if response.status() == StatusCode::OK {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
20
server/packages/opencode-adapter/Cargo.toml
Normal file
20
server/packages/opencode-adapter/Cargo.toml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
[package]
|
||||
name = "sandbox-agent-opencode-adapter"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
description.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
axum.workspace = true
|
||||
futures.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
sandbox-agent-error.workspace = true
|
||||
sandbox-agent-opencode-server-manager.workspace = true
|
||||
reqwest.workspace = true
|
||||
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-rustls", "sqlite", "migrate"] }
|
||||
26
server/packages/opencode-adapter/migrations/0001_init.sql
Normal file
26
server/packages/opencode-adapter/migrations/0001_init.sql
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
agent TEXT NOT NULL,
|
||||
agent_session_id TEXT NOT NULL,
|
||||
last_connection_id TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
destroyed_at INTEGER,
|
||||
session_init_json TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
connection_id TEXT NOT NULL,
|
||||
sender TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_events_session_created
|
||||
ON events(session_id, created_at, id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS opencode_session_metadata (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
metadata_json TEXT NOT NULL
|
||||
);
|
||||
4363
server/packages/opencode-adapter/src/lib.rs
Normal file
4363
server/packages/opencode-adapter/src/lib.rs
Normal file
File diff suppressed because it is too large
Load diff
15
server/packages/opencode-server-manager/Cargo.toml
Normal file
15
server/packages/opencode-server-manager/Cargo.toml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
[package]
|
||||
name = "sandbox-agent-opencode-server-manager"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
description.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
sandbox-agent-agent-management.workspace = true
|
||||
dirs.workspace = true
|
||||
reqwest.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
310
server/packages/opencode-server-manager/src/lib.rs
Normal file
310
server/packages/opencode-server-manager/src/lib.rs
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
use std::fs::{self, OpenOptions};
|
||||
use std::net::TcpListener;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, ExitStatus, Stdio};
|
||||
use std::sync::{Arc, Mutex as StdMutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use reqwest::Client;
|
||||
use sandbox_agent_agent_management::agents::{AgentId, AgentManager};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::time::sleep;
|
||||
use tracing::warn;
|
||||
|
||||
const HEALTH_ENDPOINTS: [&str; 4] = ["health", "healthz", "app/agents", "agents"];
|
||||
const HEALTH_ATTEMPTS: usize = 20;
|
||||
const HEALTH_DELAY_MS: u64 = 150;
|
||||
const MONITOR_DELAY_MS: u64 = 500;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OpenCodeServerManagerConfig {
|
||||
pub log_dir: PathBuf,
|
||||
pub auto_restart: bool,
|
||||
}
|
||||
|
||||
impl Default for OpenCodeServerManagerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
log_dir: default_log_dir(),
|
||||
auto_restart: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OpenCodeServerManager {
|
||||
inner: Arc<Inner>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Inner {
|
||||
agent_manager: Arc<AgentManager>,
|
||||
http_client: Client,
|
||||
config: OpenCodeServerManagerConfig,
|
||||
ensure_lock: Mutex<()>,
|
||||
state: Mutex<ManagerState>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct ManagerState {
|
||||
server: Option<RunningServer>,
|
||||
restart_count: u64,
|
||||
shutdown_requested: bool,
|
||||
last_error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct RunningServer {
|
||||
base_url: String,
|
||||
child: Arc<StdMutex<Option<Child>>>,
|
||||
instance_id: u64,
|
||||
}
|
||||
|
||||
impl OpenCodeServerManager {
|
||||
pub fn new(agent_manager: Arc<AgentManager>, config: OpenCodeServerManagerConfig) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Inner {
|
||||
agent_manager,
|
||||
http_client: Client::new(),
|
||||
config,
|
||||
ensure_lock: Mutex::new(()),
|
||||
state: Mutex::new(ManagerState::default()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn ensure_server(&self) -> Result<String, String> {
|
||||
let _guard = self.inner.ensure_lock.lock().await;
|
||||
|
||||
if let Some(base_url) = self.running_base_url().await {
|
||||
return Ok(base_url);
|
||||
}
|
||||
|
||||
let (base_url, child) = self.spawn_http_server().await?;
|
||||
|
||||
if let Err(err) = self.wait_for_http_server(&base_url).await {
|
||||
kill_child(&child);
|
||||
let mut state = self.inner.state.lock().await;
|
||||
state.last_error = Some(err.clone());
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
let instance_id = {
|
||||
let mut state = self.inner.state.lock().await;
|
||||
state.shutdown_requested = false;
|
||||
state.restart_count += 1;
|
||||
let instance_id = state.restart_count;
|
||||
state.server = Some(RunningServer {
|
||||
base_url: base_url.clone(),
|
||||
child: child.clone(),
|
||||
instance_id,
|
||||
});
|
||||
state.last_error = None;
|
||||
instance_id
|
||||
};
|
||||
|
||||
self.spawn_monitor_task(instance_id, child);
|
||||
|
||||
Ok(base_url)
|
||||
}
|
||||
|
||||
pub async fn shutdown(&self) {
|
||||
let _guard = self.inner.ensure_lock.lock().await;
|
||||
|
||||
let child = {
|
||||
let mut state = self.inner.state.lock().await;
|
||||
state.shutdown_requested = true;
|
||||
state.server.take().map(|server| server.child)
|
||||
};
|
||||
|
||||
if let Some(child) = child {
|
||||
kill_child(&child);
|
||||
}
|
||||
}
|
||||
|
||||
async fn running_base_url(&self) -> Option<String> {
|
||||
let running = {
|
||||
let state = self.inner.state.lock().await;
|
||||
state.server.clone()
|
||||
}?;
|
||||
|
||||
if child_is_alive(&running.child) {
|
||||
return Some(running.base_url);
|
||||
}
|
||||
|
||||
let mut state = self.inner.state.lock().await;
|
||||
if state
|
||||
.server
|
||||
.as_ref()
|
||||
.map(|server| server.instance_id == running.instance_id)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
state.server = None;
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
async fn wait_for_http_server(&self, base_url: &str) -> Result<(), String> {
|
||||
for _ in 0..HEALTH_ATTEMPTS {
|
||||
for endpoint in HEALTH_ENDPOINTS {
|
||||
let url = format!("{base_url}/{endpoint}");
|
||||
match self.inner.http_client.get(&url).send().await {
|
||||
Ok(response) if response.status().is_success() => return Ok(()),
|
||||
Ok(_) | Err(_) => {}
|
||||
}
|
||||
}
|
||||
sleep(Duration::from_millis(HEALTH_DELAY_MS)).await;
|
||||
}
|
||||
|
||||
Err("OpenCode server health check failed".to_string())
|
||||
}
|
||||
|
||||
async fn spawn_http_server(&self) -> Result<(String, Arc<StdMutex<Option<Child>>>), String> {
|
||||
let agent_manager = self.inner.agent_manager.clone();
|
||||
let log_dir = self.inner.config.log_dir.clone();
|
||||
|
||||
let (base_url, child) = tokio::task::spawn_blocking(move || {
|
||||
let path = agent_manager
|
||||
.resolve_binary(AgentId::Opencode)
|
||||
.map_err(|err| err.to_string())?;
|
||||
let port = find_available_port()?;
|
||||
let mut command = Command::new(path);
|
||||
let stderr = open_opencode_log(&log_dir).unwrap_or_else(|_| Stdio::null());
|
||||
command
|
||||
.arg("serve")
|
||||
.arg("--port")
|
||||
.arg(port.to_string())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(stderr);
|
||||
|
||||
let child = command.spawn().map_err(|err| err.to_string())?;
|
||||
Ok::<(String, Child), String>((format!("http://127.0.0.1:{port}"), child))
|
||||
})
|
||||
.await
|
||||
.map_err(|err| err.to_string())??;
|
||||
|
||||
Ok((base_url, Arc::new(StdMutex::new(Some(child)))))
|
||||
}
|
||||
|
||||
fn spawn_monitor_task(&self, instance_id: u64, child: Arc<StdMutex<Option<Child>>>) {
|
||||
let manager = self.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let status = {
|
||||
let mut guard = match child.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(_) => return,
|
||||
};
|
||||
match guard.as_mut() {
|
||||
Some(child) => match child.try_wait() {
|
||||
Ok(status) => status,
|
||||
Err(_) => None,
|
||||
},
|
||||
None => return,
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(status) = status {
|
||||
manager.handle_process_exit(instance_id, status).await;
|
||||
return;
|
||||
}
|
||||
|
||||
sleep(Duration::from_millis(MONITOR_DELAY_MS)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn handle_process_exit(&self, instance_id: u64, status: ExitStatus) {
|
||||
let (should_restart, error_message) = {
|
||||
let mut state = self.inner.state.lock().await;
|
||||
let Some(server) = state.server.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if server.instance_id != instance_id {
|
||||
return;
|
||||
}
|
||||
|
||||
let message = format!("OpenCode server exited with status {:?}", status);
|
||||
let shutdown_requested = state.shutdown_requested;
|
||||
if !shutdown_requested {
|
||||
state.last_error = Some(message.clone());
|
||||
}
|
||||
state.server = None;
|
||||
|
||||
(
|
||||
!shutdown_requested && self.inner.config.auto_restart,
|
||||
message,
|
||||
)
|
||||
};
|
||||
|
||||
if !should_restart {
|
||||
return;
|
||||
}
|
||||
|
||||
let manager = self.clone();
|
||||
tokio::spawn(async move {
|
||||
sleep(Duration::from_millis(MONITOR_DELAY_MS)).await;
|
||||
if let Err(err) = manager.ensure_server().await {
|
||||
warn!(
|
||||
error = ?err,
|
||||
prior_exit = %error_message,
|
||||
"failed to restart OpenCode compat sidecar"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn default_log_dir() -> PathBuf {
|
||||
let mut base = dirs::data_local_dir().unwrap_or_else(|| std::env::temp_dir());
|
||||
base.push("sandbox-agent");
|
||||
base.push("agent-logs");
|
||||
base
|
||||
}
|
||||
|
||||
fn open_opencode_log(log_dir: &Path) -> Result<Stdio, String> {
|
||||
let directory = log_dir.join("opencode");
|
||||
fs::create_dir_all(&directory).map_err(|err| err.to_string())?;
|
||||
let path = directory.join("opencode-compat.log");
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(path)
|
||||
.map_err(|err| err.to_string())?;
|
||||
Ok(file.into())
|
||||
}
|
||||
|
||||
fn find_available_port() -> Result<u16, String> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").map_err(|err| err.to_string())?;
|
||||
let port = listener.local_addr().map_err(|err| err.to_string())?.port();
|
||||
drop(listener);
|
||||
Ok(port)
|
||||
}
|
||||
|
||||
fn child_is_alive(child: &Arc<StdMutex<Option<Child>>>) -> bool {
|
||||
let mut guard = match child.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let Some(child) = guard.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => {
|
||||
*guard = None;
|
||||
false
|
||||
}
|
||||
Ok(None) => true,
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn kill_child(child: &Arc<StdMutex<Option<Child>>>) {
|
||||
if let Ok(mut guard) = child.lock() {
|
||||
if let Some(child) = guard.as_mut() {
|
||||
let _ = child.kill();
|
||||
}
|
||||
*guard = None;
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,9 @@ path = "src/main.rs"
|
|||
sandbox-agent-error.workspace = true
|
||||
sandbox-agent-agent-management.workspace = true
|
||||
sandbox-agent-agent-credentials.workspace = true
|
||||
sandbox-agent-opencode-adapter.workspace = true
|
||||
sandbox-agent-opencode-server-manager.workspace = true
|
||||
acp-http-adapter.workspace = true
|
||||
thiserror.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
|
|
|||
509
server/packages/sandbox-agent/src/acp_proxy_runtime.rs
Normal file
509
server/packages/sandbox-agent/src/acp_proxy_runtime.rs
Normal file
|
|
@ -0,0 +1,509 @@
|
|||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use acp_http_adapter::process::{AdapterError, AdapterRuntime, PostOutcome};
|
||||
use acp_http_adapter::registry::LaunchSpec;
|
||||
use axum::response::sse::Event;
|
||||
use futures::Stream;
|
||||
use sandbox_agent_agent_management::agents::{AgentId, AgentManager, InstallOptions};
|
||||
use sandbox_agent_error::SandboxError;
|
||||
use sandbox_agent_opencode_adapter::{AcpDispatch, AcpDispatchResult, AcpPayloadStream};
|
||||
use serde_json::Value;
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS: u64 = 120_000;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AcpProxyRuntime {
|
||||
inner: Arc<AcpProxyRuntimeInner>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct AcpProxyRuntimeInner {
|
||||
agent_manager: Arc<AgentManager>,
|
||||
require_preinstall: bool,
|
||||
request_timeout: Duration,
|
||||
instances: RwLock<HashMap<String, Arc<ProxyInstance>>>,
|
||||
instance_locks: Mutex<HashMap<String, Arc<Mutex<()>>>>,
|
||||
install_locks: Mutex<HashMap<AgentId, Arc<Mutex<()>>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ProxyInstance {
|
||||
server_id: String,
|
||||
agent: AgentId,
|
||||
runtime: Arc<AdapterRuntime>,
|
||||
created_at_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ProxyPostOutcome {
|
||||
Response(Value),
|
||||
Accepted,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AcpServerInstanceInfo {
|
||||
pub server_id: String,
|
||||
pub agent: AgentId,
|
||||
pub created_at_ms: i64,
|
||||
}
|
||||
|
||||
pub type PinBoxSseStream =
|
||||
std::pin::Pin<Box<dyn Stream<Item = Result<Event, std::convert::Infallible>> + Send>>;
|
||||
|
||||
impl AcpProxyRuntime {
|
||||
pub fn new(agent_manager: Arc<AgentManager>) -> Self {
|
||||
let require_preinstall = std::env::var("SANDBOX_AGENT_REQUIRE_PREINSTALL")
|
||||
.ok()
|
||||
.is_some_and(|value| {
|
||||
let trimmed = value.trim();
|
||||
trimmed == "1"
|
||||
|| trimmed.eq_ignore_ascii_case("true")
|
||||
|| trimmed.eq_ignore_ascii_case("yes")
|
||||
});
|
||||
|
||||
let request_timeout = duration_from_env_ms(
|
||||
"SANDBOX_AGENT_ACP_REQUEST_TIMEOUT_MS",
|
||||
Duration::from_millis(DEFAULT_REQUEST_TIMEOUT_MS),
|
||||
);
|
||||
|
||||
Self {
|
||||
inner: Arc::new(AcpProxyRuntimeInner {
|
||||
agent_manager,
|
||||
require_preinstall,
|
||||
request_timeout,
|
||||
instances: RwLock::new(HashMap::new()),
|
||||
instance_locks: Mutex::new(HashMap::new()),
|
||||
install_locks: Mutex::new(HashMap::new()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_instances(&self) -> Vec<AcpServerInstanceInfo> {
|
||||
let mut infos = self
|
||||
.inner
|
||||
.instances
|
||||
.read()
|
||||
.await
|
||||
.values()
|
||||
.map(|instance| AcpServerInstanceInfo {
|
||||
server_id: instance.server_id.clone(),
|
||||
agent: instance.agent,
|
||||
created_at_ms: instance.created_at_ms,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
infos.sort_by(|left, right| left.server_id.cmp(&right.server_id));
|
||||
infos
|
||||
}
|
||||
|
||||
pub async fn post(
|
||||
&self,
|
||||
server_id: &str,
|
||||
bootstrap_agent: Option<AgentId>,
|
||||
payload: Value,
|
||||
) -> Result<ProxyPostOutcome, SandboxError> {
|
||||
let method: String = payload
|
||||
.get("method")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("<none>")
|
||||
.to_string();
|
||||
let id: String = payload.get("id").map(|v| v.to_string()).unwrap_or_default();
|
||||
|
||||
tracing::info!(
|
||||
server_id = server_id,
|
||||
method = method,
|
||||
id = %id,
|
||||
bootstrap_agent = ?bootstrap_agent,
|
||||
"acp_proxy: POST received"
|
||||
);
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let instance = self
|
||||
.get_or_create_instance(server_id, bootstrap_agent)
|
||||
.await?;
|
||||
let instance_elapsed = start.elapsed();
|
||||
|
||||
tracing::debug!(
|
||||
server_id = server_id,
|
||||
agent = instance.agent.as_str(),
|
||||
instance_ms = instance_elapsed.as_millis() as u64,
|
||||
"acp_proxy: instance resolved"
|
||||
);
|
||||
|
||||
match instance.runtime.post(payload).await {
|
||||
Ok(PostOutcome::Response(value)) => {
|
||||
let total_ms = start.elapsed().as_millis() as u64;
|
||||
tracing::info!(
|
||||
server_id = server_id,
|
||||
method = method,
|
||||
id = %id,
|
||||
total_ms = total_ms,
|
||||
"acp_proxy: POST → response"
|
||||
);
|
||||
let value = annotate_agent_error(instance.agent, value);
|
||||
Ok(ProxyPostOutcome::Response(value))
|
||||
}
|
||||
Ok(PostOutcome::Accepted) => {
|
||||
tracing::info!(
|
||||
server_id = server_id,
|
||||
method = method,
|
||||
"acp_proxy: POST → accepted"
|
||||
);
|
||||
Ok(ProxyPostOutcome::Accepted)
|
||||
}
|
||||
Err(err) => {
|
||||
let total_ms = start.elapsed().as_millis() as u64;
|
||||
tracing::error!(
|
||||
server_id = server_id,
|
||||
method = method,
|
||||
id = %id,
|
||||
total_ms = total_ms,
|
||||
error = %err,
|
||||
"acp_proxy: POST → error"
|
||||
);
|
||||
Err(map_adapter_error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn sse(
|
||||
&self,
|
||||
server_id: &str,
|
||||
last_event_id: Option<u64>,
|
||||
) -> Result<PinBoxSseStream, SandboxError> {
|
||||
let instance = self.get_instance(server_id).await?;
|
||||
let stream = instance.runtime.clone().sse_stream(last_event_id).await;
|
||||
Ok(Box::pin(stream))
|
||||
}
|
||||
|
||||
pub async fn delete(&self, server_id: &str) -> Result<(), SandboxError> {
|
||||
let removed = self.inner.instances.write().await.remove(server_id);
|
||||
if let Some(instance) = removed {
|
||||
instance.runtime.shutdown().await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn shutdown_all(&self) {
|
||||
let instances = {
|
||||
let mut guard = self.inner.instances.write().await;
|
||||
guard
|
||||
.drain()
|
||||
.map(|(_, instance)| instance)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
for instance in instances {
|
||||
instance.runtime.shutdown().await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_instance(&self, server_id: &str) -> Result<Arc<ProxyInstance>, SandboxError> {
|
||||
self.inner
|
||||
.instances
|
||||
.read()
|
||||
.await
|
||||
.get(server_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| SandboxError::SessionNotFound {
|
||||
session_id: server_id.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_or_create_instance(
|
||||
&self,
|
||||
server_id: &str,
|
||||
bootstrap_agent: Option<AgentId>,
|
||||
) -> Result<Arc<ProxyInstance>, SandboxError> {
|
||||
if let Some(existing) = self.inner.instances.read().await.get(server_id).cloned() {
|
||||
if let Some(agent) = bootstrap_agent {
|
||||
if agent != existing.agent {
|
||||
return Err(SandboxError::Conflict {
|
||||
message: format!(
|
||||
"server '{server_id}' already exists for agent '{}'; requested '{agent}'",
|
||||
existing.agent.as_str()
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
return Ok(existing);
|
||||
}
|
||||
|
||||
let lock = {
|
||||
let mut locks = self.inner.instance_locks.lock().await;
|
||||
locks
|
||||
.entry(server_id.to_string())
|
||||
.or_insert_with(|| Arc::new(Mutex::new(())))
|
||||
.clone()
|
||||
};
|
||||
let _guard = lock.lock().await;
|
||||
|
||||
if let Some(existing) = self.inner.instances.read().await.get(server_id).cloned() {
|
||||
if let Some(agent) = bootstrap_agent {
|
||||
if agent != existing.agent {
|
||||
return Err(SandboxError::Conflict {
|
||||
message: format!(
|
||||
"server '{server_id}' already exists for agent '{}'; requested '{agent}'",
|
||||
existing.agent.as_str()
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
return Ok(existing);
|
||||
}
|
||||
|
||||
let agent = bootstrap_agent.ok_or_else(|| SandboxError::InvalidRequest {
|
||||
message: format!(
|
||||
"missing required 'agent' query parameter for first POST to /v1/acp/{server_id}"
|
||||
),
|
||||
})?;
|
||||
|
||||
let created = self.create_instance(server_id, agent).await?;
|
||||
self.inner
|
||||
.instances
|
||||
.write()
|
||||
.await
|
||||
.insert(server_id.to_string(), created.clone());
|
||||
|
||||
Ok(created)
|
||||
}
|
||||
|
||||
async fn create_instance(
|
||||
&self,
|
||||
server_id: &str,
|
||||
agent: AgentId,
|
||||
) -> Result<Arc<ProxyInstance>, SandboxError> {
|
||||
let start = std::time::Instant::now();
|
||||
tracing::info!(
|
||||
server_id = server_id,
|
||||
agent = agent.as_str(),
|
||||
"create_instance: starting"
|
||||
);
|
||||
|
||||
self.ensure_installed(agent).await?;
|
||||
let install_elapsed = start.elapsed();
|
||||
tracing::info!(
|
||||
server_id = server_id,
|
||||
agent = agent.as_str(),
|
||||
install_ms = install_elapsed.as_millis() as u64,
|
||||
"create_instance: agent installed/verified"
|
||||
);
|
||||
|
||||
let manager = self.inner.agent_manager.clone();
|
||||
let launch = tokio::task::spawn_blocking(move || manager.resolve_agent_process(agent))
|
||||
.await
|
||||
.map_err(|err| SandboxError::StreamError {
|
||||
message: format!("failed to resolve ACP agent process launch spec: {err}"),
|
||||
})?
|
||||
.map_err(|err| SandboxError::StreamError {
|
||||
message: err.to_string(),
|
||||
})?;
|
||||
|
||||
tracing::info!(
|
||||
server_id = server_id,
|
||||
agent = agent.as_str(),
|
||||
program = ?launch.program,
|
||||
args = ?launch.args,
|
||||
resolve_ms = start.elapsed().as_millis() as u64,
|
||||
"create_instance: launch spec resolved, spawning"
|
||||
);
|
||||
|
||||
let runtime = AdapterRuntime::start(
|
||||
LaunchSpec {
|
||||
program: launch.program,
|
||||
args: launch.args,
|
||||
env: launch.env,
|
||||
},
|
||||
self.inner.request_timeout,
|
||||
)
|
||||
.await
|
||||
.map_err(map_adapter_error)?;
|
||||
|
||||
let total_ms = start.elapsed().as_millis() as u64;
|
||||
tracing::info!(
|
||||
server_id = server_id,
|
||||
agent = agent.as_str(),
|
||||
total_ms = total_ms,
|
||||
"create_instance: ready"
|
||||
);
|
||||
|
||||
Ok(Arc::new(ProxyInstance {
|
||||
server_id: server_id.to_string(),
|
||||
agent,
|
||||
runtime: Arc::new(runtime),
|
||||
created_at_ms: now_ms(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn ensure_installed(&self, agent: AgentId) -> Result<(), SandboxError> {
|
||||
if self.inner.require_preinstall {
|
||||
if !self.is_ready(agent).await {
|
||||
return Err(SandboxError::AgentNotInstalled {
|
||||
agent: agent.as_str().to_string(),
|
||||
});
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if self.is_ready(agent).await {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let lock = {
|
||||
let mut locks = self.inner.install_locks.lock().await;
|
||||
locks
|
||||
.entry(agent)
|
||||
.or_insert_with(|| Arc::new(Mutex::new(())))
|
||||
.clone()
|
||||
};
|
||||
let _guard = lock.lock().await;
|
||||
|
||||
if self.is_ready(agent).await {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let manager = self.inner.agent_manager.clone();
|
||||
tokio::task::spawn_blocking(move || manager.install(agent, InstallOptions::default()))
|
||||
.await
|
||||
.map_err(|err| SandboxError::InstallFailed {
|
||||
agent: agent.as_str().to_string(),
|
||||
stderr: Some(format!("installer task failed: {err}")),
|
||||
})?
|
||||
.map_err(|err| SandboxError::InstallFailed {
|
||||
agent: agent.as_str().to_string(),
|
||||
stderr: Some(err.to_string()),
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn is_ready(&self, agent: AgentId) -> bool {
|
||||
if agent == AgentId::Mock {
|
||||
return self.inner.agent_manager.agent_process_path(agent).exists();
|
||||
}
|
||||
self.inner.agent_manager.is_installed(agent)
|
||||
}
|
||||
}
|
||||
|
||||
impl AcpDispatch for AcpProxyRuntime {
|
||||
fn post(
|
||||
&self,
|
||||
server_id: &str,
|
||||
bootstrap_agent: Option<&str>,
|
||||
payload: Value,
|
||||
) -> Pin<Box<dyn Future<Output = Result<AcpDispatchResult, String>> + Send + '_>> {
|
||||
let server_id = server_id.to_string();
|
||||
let agent = bootstrap_agent.and_then(AgentId::parse);
|
||||
Box::pin(async move {
|
||||
match self.post(&server_id, agent, payload).await {
|
||||
Ok(ProxyPostOutcome::Response(value)) => Ok(AcpDispatchResult::Response(value)),
|
||||
Ok(ProxyPostOutcome::Accepted) => Ok(AcpDispatchResult::Accepted),
|
||||
Err(err) => Err(err.to_string()),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn notification_stream(
|
||||
&self,
|
||||
server_id: &str,
|
||||
last_event_id: Option<u64>,
|
||||
) -> Pin<Box<dyn Future<Output = Result<AcpPayloadStream, String>> + Send + '_>> {
|
||||
let server_id = server_id.to_string();
|
||||
Box::pin(async move {
|
||||
let instance = self
|
||||
.get_instance(&server_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let stream = instance.runtime.clone().value_stream(last_event_id).await;
|
||||
Ok(Box::pin(stream) as AcpPayloadStream)
|
||||
})
|
||||
}
|
||||
|
||||
fn delete(
|
||||
&self,
|
||||
server_id: &str,
|
||||
) -> Pin<Box<dyn Future<Output = Result<(), String>> + Send + '_>> {
|
||||
let server_id = server_id.to_string();
|
||||
Box::pin(async move { self.delete(&server_id).await.map_err(|err| err.to_string()) })
|
||||
}
|
||||
}
|
||||
|
||||
fn map_adapter_error(err: AdapterError) -> SandboxError {
|
||||
match err {
|
||||
AdapterError::InvalidEnvelope => SandboxError::InvalidRequest {
|
||||
message: "request body must be a JSON-RPC object".to_string(),
|
||||
},
|
||||
AdapterError::Timeout => SandboxError::Timeout {
|
||||
message: Some("timed out waiting for agent response".to_string()),
|
||||
},
|
||||
AdapterError::Serialize(error) => SandboxError::InvalidRequest {
|
||||
message: format!("failed to serialize JSON payload: {error}"),
|
||||
},
|
||||
AdapterError::Write(error) => SandboxError::StreamError {
|
||||
message: format!("failed writing to agent stdin: {error}"),
|
||||
},
|
||||
AdapterError::Spawn(error) => SandboxError::StreamError {
|
||||
message: format!("failed to start agent process: {error}"),
|
||||
},
|
||||
AdapterError::MissingStdin | AdapterError::MissingStdout | AdapterError::MissingStderr => {
|
||||
SandboxError::StreamError {
|
||||
message: "agent subprocess pipes were not available".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inspect JSON-RPC error responses from agent processes and add helpful hints
|
||||
/// when we can infer the root cause from a known error pattern.
|
||||
fn annotate_agent_error(agent: AgentId, mut value: Value) -> Value {
|
||||
if agent != AgentId::Pi {
|
||||
return value;
|
||||
}
|
||||
|
||||
let matches = value
|
||||
.pointer("/error/data/details")
|
||||
.and_then(|v| v.as_str())
|
||||
.is_some_and(|s| s.contains("Cannot call write after a stream was destroyed"));
|
||||
|
||||
if matches {
|
||||
if let Some(data) = value.pointer_mut("/error/data") {
|
||||
if let Some(obj) = data.as_object_mut() {
|
||||
obj.insert(
|
||||
"hint".to_string(),
|
||||
Value::String(
|
||||
"The pi CLI exited immediately — this usually means no API key is \
|
||||
configured. Set ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, \
|
||||
or another supported provider key."
|
||||
.to_string(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
value
|
||||
}
|
||||
|
||||
fn duration_from_env_ms(key: &str, default: Duration) -> Duration {
|
||||
match std::env::var(key) {
|
||||
Ok(raw) => raw
|
||||
.trim()
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.filter(|value| *value > 0)
|
||||
.map(Duration::from_millis)
|
||||
.unwrap_or(default),
|
||||
Err(_) => default,
|
||||
}
|
||||
}
|
||||
|
||||
fn now_ms() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
|
@ -1,330 +0,0 @@
|
|||
use super::*;
|
||||
impl SharedAgentBackend {
|
||||
pub(super) fn new_mock(agent: AgentId) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
agent,
|
||||
sender: BackendSender::Mock(new_mock_backend()),
|
||||
pending_client_responses: Mutex::new(HashMap::new()),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn new_process(
|
||||
agent: AgentId,
|
||||
launch: AgentProcessLaunchSpec,
|
||||
runtime: Arc<AcpRuntimeInner>,
|
||||
) -> Result<Arc<Self>, SandboxError> {
|
||||
let mut command = Command::new(&launch.program);
|
||||
command
|
||||
.args(&launch.args)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped());
|
||||
|
||||
for (key, value) in &launch.env {
|
||||
command.env(key, value);
|
||||
}
|
||||
|
||||
let mut child = command.spawn().map_err(|err| SandboxError::StreamError {
|
||||
message: format!(
|
||||
"failed to start ACP agent process {}: {err}",
|
||||
launch.program.display()
|
||||
),
|
||||
})?;
|
||||
|
||||
let stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| SandboxError::StreamError {
|
||||
message: "failed to capture ACP agent process stdin".to_string(),
|
||||
})?;
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| SandboxError::StreamError {
|
||||
message: "failed to capture ACP agent process stdout".to_string(),
|
||||
})?;
|
||||
let stderr = child
|
||||
.stderr
|
||||
.take()
|
||||
.ok_or_else(|| SandboxError::StreamError {
|
||||
message: "failed to capture ACP agent process stderr".to_string(),
|
||||
})?;
|
||||
|
||||
let process = ProcessBackend {
|
||||
stdin: Arc::new(Mutex::new(stdin)),
|
||||
child: Arc::new(Mutex::new(child)),
|
||||
stderr_capture: Arc::new(Mutex::new(StderrCapture::default())),
|
||||
terminate_requested: Arc::new(AtomicBool::new(false)),
|
||||
};
|
||||
let backend = Arc::new(Self {
|
||||
agent,
|
||||
sender: BackendSender::Process(process.clone()),
|
||||
pending_client_responses: Mutex::new(HashMap::new()),
|
||||
});
|
||||
|
||||
backend.start_process_pumps(runtime, stdout, stderr, process);
|
||||
Ok(backend)
|
||||
}
|
||||
|
||||
pub(super) async fn is_alive(&self) -> bool {
|
||||
match &self.sender {
|
||||
BackendSender::Mock(_) => true,
|
||||
BackendSender::Process(process) => process.is_alive().await,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn start_process_pumps(
|
||||
self: &Arc<Self>,
|
||||
runtime: Arc<AcpRuntimeInner>,
|
||||
stdout: tokio::process::ChildStdout,
|
||||
stderr: tokio::process::ChildStderr,
|
||||
process: ProcessBackend,
|
||||
) {
|
||||
let backend = self.clone();
|
||||
let runtime_stdout = runtime.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(stdout).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let message = match serde_json::from_str::<Value>(&line) {
|
||||
Ok(message) => message,
|
||||
Err(err) => json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": AGENT_UNPARSED_METHOD,
|
||||
"params": {
|
||||
"error": err.to_string(),
|
||||
"raw": line,
|
||||
},
|
||||
}),
|
||||
};
|
||||
runtime_stdout
|
||||
.handle_backend_message(backend.agent, message)
|
||||
.await;
|
||||
}
|
||||
});
|
||||
|
||||
let backend = self.clone();
|
||||
let stderr_capture = process.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(stderr).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
stderr_capture.record_stderr_line(line.clone()).await;
|
||||
tracing::debug!(
|
||||
agent = %backend.agent,
|
||||
"ACP agent process stderr: {}",
|
||||
line
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
let backend = self.clone();
|
||||
let runtime_exit = runtime.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let probe = {
|
||||
let mut child = process.child.lock().await;
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => Ok(Some(status)),
|
||||
Ok(None) => Ok(None),
|
||||
Err(err) => Err(err.to_string()),
|
||||
}
|
||||
};
|
||||
|
||||
match probe {
|
||||
Ok(Some(status)) => {
|
||||
runtime_exit
|
||||
.remove_backend_if_same(backend.agent, &backend)
|
||||
.await;
|
||||
runtime_exit
|
||||
.handle_backend_process_exit(
|
||||
backend.agent,
|
||||
Some(status),
|
||||
process.terminated_by(),
|
||||
process.stderr_output().await,
|
||||
)
|
||||
.await;
|
||||
break;
|
||||
}
|
||||
Ok(None) => {
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
Err(err) => {
|
||||
runtime_exit
|
||||
.remove_backend_if_same(backend.agent, &backend)
|
||||
.await;
|
||||
runtime_exit
|
||||
.mark_backend_stopped(
|
||||
backend.agent,
|
||||
Some(format!("failed to poll ACP agent process status: {err}")),
|
||||
)
|
||||
.await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) async fn send(
|
||||
&self,
|
||||
runtime: Arc<AcpRuntimeInner>,
|
||||
payload: Value,
|
||||
) -> Result<(), SandboxError> {
|
||||
match &self.sender {
|
||||
BackendSender::Process(process) => {
|
||||
let mut stdin = process.stdin.lock().await;
|
||||
let encoded =
|
||||
serde_json::to_vec(&payload).map_err(|err| SandboxError::InvalidRequest {
|
||||
message: format!("failed to serialize JSON-RPC payload: {err}"),
|
||||
})?;
|
||||
if let Err(err) = stdin.write_all(&encoded).await {
|
||||
let message = format!("failed to write to ACP agent process stdin: {err}");
|
||||
runtime
|
||||
.mark_backend_stopped(self.agent, Some(message.clone()))
|
||||
.await;
|
||||
return Err(SandboxError::StreamError { message });
|
||||
}
|
||||
if let Err(err) = stdin.write_all(b"\n").await {
|
||||
let message =
|
||||
format!("failed to write line delimiter to ACP agent process stdin: {err}");
|
||||
runtime
|
||||
.mark_backend_stopped(self.agent, Some(message.clone()))
|
||||
.await;
|
||||
return Err(SandboxError::StreamError { message });
|
||||
}
|
||||
if let Err(err) = stdin.flush().await {
|
||||
let message = format!("failed to flush ACP agent process stdin: {err}");
|
||||
runtime
|
||||
.mark_backend_stopped(self.agent, Some(message.clone()))
|
||||
.await;
|
||||
return Err(SandboxError::StreamError { message });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
BackendSender::Mock(mock) => {
|
||||
let agent = self.agent;
|
||||
Box::pin(handle_mock_payload(mock, &payload, |message| {
|
||||
let runtime = runtime.clone();
|
||||
async move {
|
||||
runtime.handle_backend_message(agent, message).await;
|
||||
}
|
||||
}))
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn shutdown(&self, grace: Duration) {
|
||||
if let BackendSender::Process(process) = &self.sender {
|
||||
process.terminate_requested.store(true, Ordering::SeqCst);
|
||||
tokio::time::sleep(grace).await;
|
||||
let mut child = process.child.lock().await;
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => {}
|
||||
Ok(None) => {
|
||||
let _ = child.kill().await;
|
||||
let _ = child.wait().await;
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = child.kill().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProcessBackend {
|
||||
pub(super) async fn record_stderr_line(&self, line: String) {
|
||||
self.stderr_capture.lock().await.record(line);
|
||||
}
|
||||
|
||||
pub(super) async fn stderr_output(&self) -> Option<StderrOutput> {
|
||||
self.stderr_capture.lock().await.snapshot()
|
||||
}
|
||||
|
||||
pub(super) async fn is_alive(&self) -> bool {
|
||||
let mut child = self.child.lock().await;
|
||||
matches!(child.try_wait(), Ok(None))
|
||||
}
|
||||
|
||||
pub(super) fn terminated_by(&self) -> TerminatedBy {
|
||||
if self.terminate_requested.load(Ordering::SeqCst) {
|
||||
TerminatedBy::Daemon
|
||||
} else {
|
||||
TerminatedBy::Agent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AcpClient {
|
||||
pub(super) fn new(id: String, default_agent: AgentId) -> Arc<Self> {
|
||||
let (sender, _rx) = broadcast::channel(512);
|
||||
Arc::new(Self {
|
||||
id,
|
||||
default_agent,
|
||||
seq: AtomicU64::new(0),
|
||||
closed: AtomicBool::new(false),
|
||||
sse_stream_active: Arc::new(AtomicBool::new(false)),
|
||||
sender,
|
||||
ring: Mutex::new(VecDeque::with_capacity(RING_BUFFER_SIZE)),
|
||||
pending: Mutex::new(HashMap::new()),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn push_stream(&self, payload: Value) {
|
||||
let sequence = self.seq.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
let message = StreamMessage { sequence, payload };
|
||||
|
||||
{
|
||||
let mut ring = self.ring.lock().await;
|
||||
ring.push_back(message.clone());
|
||||
while ring.len() > RING_BUFFER_SIZE {
|
||||
ring.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
let _ = self.sender.send(message);
|
||||
}
|
||||
|
||||
pub(super) async fn subscribe(
|
||||
&self,
|
||||
last_event_id: Option<u64>,
|
||||
) -> (Vec<StreamMessage>, broadcast::Receiver<StreamMessage>) {
|
||||
let replay = {
|
||||
let ring = self.ring.lock().await;
|
||||
ring.iter()
|
||||
.filter(|message| {
|
||||
if let Some(last_event_id) = last_event_id {
|
||||
message.sequence > last_event_id
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
(replay, self.sender.subscribe())
|
||||
}
|
||||
|
||||
pub(super) fn try_claim_sse_stream(&self) -> bool {
|
||||
self.sse_stream_active
|
||||
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
pub(super) fn sse_active_flag(&self) -> Arc<AtomicBool> {
|
||||
self.sse_stream_active.clone()
|
||||
}
|
||||
|
||||
pub(super) async fn close(&self) {
|
||||
if self.closed.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.sse_stream_active.store(false, Ordering::SeqCst);
|
||||
self.pending.lock().await.clear();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
use super::*;
|
||||
|
||||
// Canonical extension namespace used for ACP _meta values.
|
||||
pub(super) const SANDBOX_META_KEY: &str = "sandboxagent.dev";
|
||||
|
||||
// _meta[sandboxagent.dev].extensions key in initialize response.
|
||||
pub(super) const EXTENSIONS_META_KEY: &str = "extensions";
|
||||
// _meta[sandboxagent.dev].extensions.sessionDetach => method _sandboxagent/session/detach
|
||||
pub(super) const EXTENSION_KEY_SESSION_DETACH: &str = "sessionDetach";
|
||||
// _meta[sandboxagent.dev].extensions.sessionTerminate => method _sandboxagent/session/terminate
|
||||
pub(super) const EXTENSION_KEY_SESSION_TERMINATE: &str = "sessionTerminate";
|
||||
// _meta[sandboxagent.dev].extensions.sessionEndedNotification => method _sandboxagent/session/ended
|
||||
pub(super) const EXTENSION_KEY_SESSION_ENDED_NOTIFICATION: &str = "sessionEndedNotification";
|
||||
// _meta[sandboxagent.dev].extensions.sessionListModels => method _sandboxagent/session/list_models
|
||||
pub(super) const EXTENSION_KEY_SESSION_LIST_MODELS: &str = "sessionListModels";
|
||||
// _meta[sandboxagent.dev].extensions.sessionSetMetadata => method _sandboxagent/session/set_metadata
|
||||
pub(super) const EXTENSION_KEY_SESSION_SET_METADATA: &str = "sessionSetMetadata";
|
||||
// _meta[sandboxagent.dev].extensions.sessionAgentMeta => session/new + initialize require _meta[sandboxagent.dev].agent
|
||||
pub(super) const EXTENSION_KEY_SESSION_AGENT_META: &str = "sessionAgentMeta";
|
||||
// _meta[sandboxagent.dev].extensions.agentList => method _sandboxagent/agent/list
|
||||
pub(super) const EXTENSION_KEY_AGENT_LIST: &str = "agentList";
|
||||
// _meta[sandboxagent.dev].extensions.agentInstall => method _sandboxagent/agent/install
|
||||
pub(super) const EXTENSION_KEY_AGENT_INSTALL: &str = "agentInstall";
|
||||
// _meta[sandboxagent.dev].extensions.sessionList => method _sandboxagent/session/list
|
||||
pub(super) const EXTENSION_KEY_SESSION_LIST: &str = "sessionList";
|
||||
// _meta[sandboxagent.dev].extensions.sessionGet => method _sandboxagent/session/get
|
||||
pub(super) const EXTENSION_KEY_SESSION_GET: &str = "sessionGet";
|
||||
// _meta[sandboxagent.dev].extensions.fsListEntries => method _sandboxagent/fs/list_entries
|
||||
pub(super) const EXTENSION_KEY_FS_LIST_ENTRIES: &str = "fsListEntries";
|
||||
// _meta[sandboxagent.dev].extensions.fsReadFile => method _sandboxagent/fs/read_file
|
||||
pub(super) const EXTENSION_KEY_FS_READ_FILE: &str = "fsReadFile";
|
||||
// _meta[sandboxagent.dev].extensions.fsWriteFile => method _sandboxagent/fs/write_file
|
||||
pub(super) const EXTENSION_KEY_FS_WRITE_FILE: &str = "fsWriteFile";
|
||||
// _meta[sandboxagent.dev].extensions.fsDeleteEntry => method _sandboxagent/fs/delete_entry
|
||||
pub(super) const EXTENSION_KEY_FS_DELETE_ENTRY: &str = "fsDeleteEntry";
|
||||
// _meta[sandboxagent.dev].extensions.fsMkdir => method _sandboxagent/fs/mkdir
|
||||
pub(super) const EXTENSION_KEY_FS_MKDIR: &str = "fsMkdir";
|
||||
// _meta[sandboxagent.dev].extensions.fsMove => method _sandboxagent/fs/move
|
||||
pub(super) const EXTENSION_KEY_FS_MOVE: &str = "fsMove";
|
||||
// _meta[sandboxagent.dev].extensions.fsStat => method _sandboxagent/fs/stat
|
||||
pub(super) const EXTENSION_KEY_FS_STAT: &str = "fsStat";
|
||||
// _meta[sandboxagent.dev].extensions.fsUploadBatch => method _sandboxagent/fs/upload_batch
|
||||
pub(super) const EXTENSION_KEY_FS_UPLOAD_BATCH: &str = "fsUploadBatch";
|
||||
// _meta[sandboxagent.dev].extensions.methods => list of supported extension methods
|
||||
pub(super) const EXTENSION_KEY_METHODS: &str = "methods";
|
||||
|
||||
pub(super) fn extract_sandbox_session_meta(payload: &Value) -> Option<Map<String, Value>> {
|
||||
payload
|
||||
.get("params")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|params| params.get("_meta"))
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|meta| meta.get(SANDBOX_META_KEY))
|
||||
.and_then(Value::as_object)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub(super) fn inject_extension_capabilities(payload: &mut Value) {
|
||||
let Some(result) = payload.get_mut("result").and_then(Value::as_object_mut) else {
|
||||
return;
|
||||
};
|
||||
let Some(agent_capabilities) = result
|
||||
.get_mut("agentCapabilities")
|
||||
.and_then(Value::as_object_mut)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let meta = agent_capabilities
|
||||
.entry("_meta".to_string())
|
||||
.or_insert_with(|| Value::Object(Map::new()));
|
||||
let Some(meta_object) = meta.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
let sandbox = meta_object
|
||||
.entry(SANDBOX_META_KEY.to_string())
|
||||
.or_insert_with(|| Value::Object(Map::new()));
|
||||
let Some(sandbox_object) = sandbox.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
sandbox_object.insert(
|
||||
EXTENSIONS_META_KEY.to_string(),
|
||||
json!({
|
||||
EXTENSION_KEY_SESSION_DETACH: true,
|
||||
EXTENSION_KEY_SESSION_TERMINATE: true,
|
||||
EXTENSION_KEY_SESSION_ENDED_NOTIFICATION: true,
|
||||
EXTENSION_KEY_SESSION_LIST_MODELS: true,
|
||||
EXTENSION_KEY_SESSION_SET_METADATA: true,
|
||||
EXTENSION_KEY_SESSION_AGENT_META: true,
|
||||
EXTENSION_KEY_AGENT_LIST: true,
|
||||
EXTENSION_KEY_AGENT_INSTALL: true,
|
||||
EXTENSION_KEY_SESSION_LIST: true,
|
||||
EXTENSION_KEY_SESSION_GET: true,
|
||||
EXTENSION_KEY_FS_LIST_ENTRIES: true,
|
||||
EXTENSION_KEY_FS_READ_FILE: true,
|
||||
EXTENSION_KEY_FS_WRITE_FILE: true,
|
||||
EXTENSION_KEY_FS_DELETE_ENTRY: true,
|
||||
EXTENSION_KEY_FS_MKDIR: true,
|
||||
EXTENSION_KEY_FS_MOVE: true,
|
||||
EXTENSION_KEY_FS_STAT: true,
|
||||
EXTENSION_KEY_FS_UPLOAD_BATCH: true,
|
||||
EXTENSION_KEY_METHODS: [
|
||||
SESSION_DETACH_METHOD,
|
||||
SESSION_TERMINATE_METHOD,
|
||||
SESSION_ENDED_METHOD,
|
||||
SESSION_LIST_MODELS_METHOD,
|
||||
SESSION_SET_METADATA_METHOD,
|
||||
AGENT_LIST_METHOD,
|
||||
AGENT_INSTALL_METHOD,
|
||||
SESSION_LIST_METHOD,
|
||||
SESSION_GET_METHOD,
|
||||
FS_LIST_ENTRIES_METHOD,
|
||||
FS_READ_FILE_METHOD,
|
||||
FS_WRITE_FILE_METHOD,
|
||||
FS_DELETE_ENTRY_METHOD,
|
||||
FS_MKDIR_METHOD,
|
||||
FS_MOVE_METHOD,
|
||||
FS_STAT_METHOD,
|
||||
FS_UPLOAD_BATCH_METHOD,
|
||||
]
|
||||
}),
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,573 +0,0 @@
|
|||
use super::*;
|
||||
pub(super) fn validate_jsonrpc_envelope(payload: &Value) -> Result<(), SandboxError> {
|
||||
let object = payload
|
||||
.as_object()
|
||||
.ok_or_else(|| SandboxError::InvalidRequest {
|
||||
message: "JSON-RPC payload must be an object".to_string(),
|
||||
})?;
|
||||
|
||||
let Some(jsonrpc) = object.get("jsonrpc").and_then(Value::as_str) else {
|
||||
return Err(SandboxError::InvalidRequest {
|
||||
message: "JSON-RPC payload must include jsonrpc field".to_string(),
|
||||
});
|
||||
};
|
||||
|
||||
if jsonrpc != "2.0" {
|
||||
return Err(SandboxError::InvalidRequest {
|
||||
message: "jsonrpc must be '2.0'".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let has_method = object.get("method").is_some();
|
||||
let has_id = object.get("id").is_some();
|
||||
let has_result_or_error = object.get("result").is_some() || object.get("error").is_some();
|
||||
|
||||
if !has_method && !has_id {
|
||||
return Err(SandboxError::InvalidRequest {
|
||||
message: "JSON-RPC payload must include either method or id".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if has_method && has_result_or_error {
|
||||
return Err(SandboxError::InvalidRequest {
|
||||
message: "JSON-RPC request/notification must not include result or error".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn required_sandbox_agent_meta(
|
||||
payload: &Value,
|
||||
method: &str,
|
||||
) -> Result<AgentId, SandboxError> {
|
||||
let Some(agent) = payload
|
||||
.get("params")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|params| params.get("_meta"))
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|meta| meta.get(SANDBOX_META_KEY))
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|sandbox| sandbox.get("agent"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
else {
|
||||
return Err(SandboxError::InvalidRequest {
|
||||
message: format!("{method} requires params._meta[\"{SANDBOX_META_KEY}\"].agent"),
|
||||
});
|
||||
};
|
||||
AgentId::parse(agent).ok_or_else(|| SandboxError::UnsupportedAgent {
|
||||
agent: agent.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn explicit_agent_param(payload: &Value) -> Result<Option<AgentId>, SandboxError> {
|
||||
let Some(agent_value) = payload
|
||||
.get("params")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|params| params.get("agent"))
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(agent_name) = agent_value.as_str() else {
|
||||
return Err(SandboxError::InvalidRequest {
|
||||
message: "params.agent must be a string".to_string(),
|
||||
});
|
||||
};
|
||||
let agent_name = agent_name.trim();
|
||||
if agent_name.is_empty() {
|
||||
return Err(SandboxError::InvalidRequest {
|
||||
message: "params.agent must be non-empty".to_string(),
|
||||
});
|
||||
}
|
||||
AgentId::parse(agent_name)
|
||||
.map(Some)
|
||||
.ok_or_else(|| SandboxError::UnsupportedAgent {
|
||||
agent: agent_name.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn to_sse_event(message: StreamMessage) -> Event {
|
||||
let data = serde_json::to_string(&message.payload).unwrap_or_else(|_| "{}".to_string());
|
||||
Event::default()
|
||||
.event("message")
|
||||
.id(message.sequence.to_string())
|
||||
.data(data)
|
||||
}
|
||||
|
||||
pub(super) fn message_id_key(id: &Value) -> String {
|
||||
serde_json::to_string(id).unwrap_or_else(|_| "null".to_string())
|
||||
}
|
||||
|
||||
pub(super) fn set_payload_id(payload: &mut Value, id: Value) {
|
||||
if let Some(object) = payload.as_object_mut() {
|
||||
object.insert("id".to_string(), id);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn extract_session_id_from_payload(payload: &Value) -> Option<String> {
|
||||
payload
|
||||
.get("params")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|params| params.get("sessionId"))
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
pub(super) fn extract_session_id_from_response(payload: &Value) -> Option<String> {
|
||||
payload
|
||||
.get("result")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|result| result.get("sessionId"))
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
pub(super) fn extract_cwd_from_payload(payload: &Value) -> Option<String> {
|
||||
payload
|
||||
.get("params")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|params| params.get("cwd"))
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
pub(super) fn extract_model_id_from_payload(payload: &Value) -> Option<String> {
|
||||
payload
|
||||
.get("params")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|params| params.get("modelId"))
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
pub(super) fn extract_mode_id_from_payload(payload: &Value) -> Option<String> {
|
||||
payload
|
||||
.get("params")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|params| params.get("modeId"))
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
pub(super) fn extract_models_from_response(response: &Value) -> Option<AgentModelSnapshot> {
|
||||
let result = response.get("result")?.as_object()?;
|
||||
let models_root = result
|
||||
.get("models")
|
||||
.and_then(Value::as_object)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| result.clone());
|
||||
let available_models = models_root
|
||||
.get("availableModels")
|
||||
.and_then(Value::as_array)?
|
||||
.iter()
|
||||
.filter_map(|entry| {
|
||||
let object = entry.as_object()?;
|
||||
let model_id = object.get("modelId").and_then(Value::as_str)?.to_string();
|
||||
let mut variants = object
|
||||
.get("variants")
|
||||
.and_then(Value::as_array)
|
||||
.map(|values| {
|
||||
values
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
variants.sort();
|
||||
Some(AgentModelInfo {
|
||||
model_id,
|
||||
name: object
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
description: object
|
||||
.get("description")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
default_variant: object
|
||||
.get("defaultVariant")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
variants,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let current_model_id = models_root
|
||||
.get("currentModelId")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| {
|
||||
available_models
|
||||
.first()
|
||||
.map(|entry| entry.model_id.to_string())
|
||||
});
|
||||
Some(AgentModelSnapshot {
|
||||
available_models,
|
||||
current_model_id,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn extract_modes_from_response(response: &Value) -> Option<AgentModeSnapshot> {
|
||||
let result = response.get("result")?.as_object()?;
|
||||
let modes_root = result
|
||||
.get("modes")
|
||||
.and_then(Value::as_object)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| result.clone());
|
||||
let available_modes = modes_root
|
||||
.get("availableModes")
|
||||
.and_then(Value::as_array)?
|
||||
.iter()
|
||||
.filter_map(|entry| {
|
||||
let object = entry.as_object()?;
|
||||
let mode_id = object
|
||||
.get("modeId")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| object.get("id").and_then(Value::as_str))?
|
||||
.to_string();
|
||||
Some(AgentModeInfo {
|
||||
mode_id,
|
||||
name: object
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
description: object
|
||||
.get("description")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let current_mode_id = modes_root
|
||||
.get("currentModeId")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| {
|
||||
available_modes
|
||||
.first()
|
||||
.map(|entry| entry.mode_id.to_string())
|
||||
});
|
||||
Some(AgentModeSnapshot {
|
||||
available_modes,
|
||||
current_mode_id,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn fallback_models_for_agent(agent: AgentId) -> AgentModelSnapshot {
|
||||
match agent {
|
||||
// Copied from pre-ACP v1 fallback behavior in router.rs.
|
||||
AgentId::Claude => AgentModelSnapshot {
|
||||
available_models: vec![
|
||||
AgentModelInfo {
|
||||
model_id: "default".to_string(),
|
||||
name: Some("Default (recommended)".to_string()),
|
||||
description: None,
|
||||
default_variant: None,
|
||||
variants: Vec::new(),
|
||||
},
|
||||
AgentModelInfo {
|
||||
model_id: "sonnet".to_string(),
|
||||
name: Some("Sonnet".to_string()),
|
||||
description: None,
|
||||
default_variant: None,
|
||||
variants: Vec::new(),
|
||||
},
|
||||
AgentModelInfo {
|
||||
model_id: "opus".to_string(),
|
||||
name: Some("Opus".to_string()),
|
||||
description: None,
|
||||
default_variant: None,
|
||||
variants: Vec::new(),
|
||||
},
|
||||
AgentModelInfo {
|
||||
model_id: "haiku".to_string(),
|
||||
name: Some("Haiku".to_string()),
|
||||
description: None,
|
||||
default_variant: None,
|
||||
variants: Vec::new(),
|
||||
},
|
||||
],
|
||||
current_model_id: Some("default".to_string()),
|
||||
},
|
||||
AgentId::Amp => AgentModelSnapshot {
|
||||
available_models: vec![AgentModelInfo {
|
||||
model_id: "amp-default".to_string(),
|
||||
name: Some("Amp Default".to_string()),
|
||||
description: None,
|
||||
default_variant: None,
|
||||
variants: Vec::new(),
|
||||
}],
|
||||
current_model_id: Some("amp-default".to_string()),
|
||||
},
|
||||
AgentId::Mock => AgentModelSnapshot {
|
||||
available_models: vec![AgentModelInfo {
|
||||
model_id: "mock".to_string(),
|
||||
name: Some("Mock".to_string()),
|
||||
description: None,
|
||||
default_variant: None,
|
||||
variants: Vec::new(),
|
||||
}],
|
||||
current_model_id: Some("mock".to_string()),
|
||||
},
|
||||
AgentId::Codex | AgentId::Opencode => AgentModelSnapshot::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn to_stream_error(
|
||||
error: sandbox_agent_agent_management::agents::AgentError,
|
||||
) -> SandboxError {
|
||||
SandboxError::StreamError {
|
||||
message: error.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn duration_from_env_ms(var_name: &str, default: Duration) -> Duration {
|
||||
std::env::var(var_name)
|
||||
.ok()
|
||||
.and_then(|value| value.trim().parse::<u64>().ok())
|
||||
.filter(|value| *value > 0)
|
||||
.map(Duration::from_millis)
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
impl SessionEndReason {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Completed => "completed",
|
||||
Self::Error => "error",
|
||||
Self::Terminated => "terminated",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TerminatedBy {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Agent => "agent",
|
||||
Self::Daemon => "daemon",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StderrCapture {
|
||||
pub(super) fn record(&mut self, line: String) {
|
||||
self.total_lines = self.total_lines.saturating_add(1);
|
||||
if self.full_if_small.len() < STDERR_HEAD_LINES + STDERR_TAIL_LINES {
|
||||
self.full_if_small.push(line.clone());
|
||||
}
|
||||
if self.head.len() < STDERR_HEAD_LINES {
|
||||
self.head.push(line.clone());
|
||||
}
|
||||
self.tail.push_back(line);
|
||||
while self.tail.len() > STDERR_TAIL_LINES {
|
||||
self.tail.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn snapshot(&self) -> Option<StderrOutput> {
|
||||
if self.total_lines == 0 {
|
||||
return None;
|
||||
}
|
||||
let max_untruncated = STDERR_HEAD_LINES + STDERR_TAIL_LINES;
|
||||
if self.total_lines <= max_untruncated {
|
||||
let head = if self.full_if_small.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(self.full_if_small.join("\n"))
|
||||
};
|
||||
return Some(StderrOutput {
|
||||
head,
|
||||
tail: None,
|
||||
truncated: false,
|
||||
total_lines: Some(self.total_lines),
|
||||
});
|
||||
}
|
||||
Some(StderrOutput {
|
||||
head: if self.head.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(self.head.join("\n"))
|
||||
},
|
||||
tail: if self.tail.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(self.tail.iter().cloned().collect::<Vec<_>>().join("\n"))
|
||||
},
|
||||
truncated: true,
|
||||
total_lines: Some(self.total_lines),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MetaSession> for SessionRuntimeInfo {
|
||||
fn from(value: MetaSession) -> Self {
|
||||
Self {
|
||||
session_id: value.session_id,
|
||||
created_at: value.created_at,
|
||||
updated_at: value.updated_at_ms,
|
||||
ended: value.ended,
|
||||
event_count: value.event_count,
|
||||
model_hint: value.model_hint,
|
||||
mode_hint: value.mode_hint,
|
||||
title: value.title,
|
||||
cwd: value.cwd,
|
||||
sandbox_meta: value.sandbox_meta,
|
||||
agent: value.agent,
|
||||
ended_data: value.ended_data,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AgentModelSnapshot> for RuntimeModelSnapshot {
|
||||
fn from(value: AgentModelSnapshot) -> Self {
|
||||
Self {
|
||||
available_models: value
|
||||
.available_models
|
||||
.into_iter()
|
||||
.map(|model| RuntimeModelInfo {
|
||||
model_id: model.model_id,
|
||||
name: model.name,
|
||||
description: model.description,
|
||||
})
|
||||
.collect(),
|
||||
current_model_id: value.current_model_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AgentModeSnapshot> for RuntimeModeSnapshot {
|
||||
fn from(value: AgentModeSnapshot) -> Self {
|
||||
Self {
|
||||
available_modes: value
|
||||
.available_modes
|
||||
.into_iter()
|
||||
.map(|mode| RuntimeModeInfo {
|
||||
mode_id: mode.mode_id,
|
||||
name: mode.name,
|
||||
description: mode.description,
|
||||
})
|
||||
.collect(),
|
||||
current_mode_id: value.current_mode_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn ended_data_to_value(data: &SessionEndedData) -> Value {
|
||||
let mut output = Map::new();
|
||||
output.insert(
|
||||
"reason".to_string(),
|
||||
Value::String(data.reason.as_str().to_string()),
|
||||
);
|
||||
output.insert(
|
||||
"terminated_by".to_string(),
|
||||
Value::String(data.terminated_by.as_str().to_string()),
|
||||
);
|
||||
if let Some(message) = &data.message {
|
||||
output.insert("message".to_string(), Value::String(message.clone()));
|
||||
}
|
||||
if let Some(exit_code) = data.exit_code {
|
||||
output.insert("exit_code".to_string(), Value::from(exit_code));
|
||||
}
|
||||
if let Some(stderr) = &data.stderr {
|
||||
let mut stderr_value = Map::new();
|
||||
if let Some(head) = &stderr.head {
|
||||
stderr_value.insert("head".to_string(), Value::String(head.clone()));
|
||||
}
|
||||
if let Some(tail) = &stderr.tail {
|
||||
stderr_value.insert("tail".to_string(), Value::String(tail.clone()));
|
||||
}
|
||||
stderr_value.insert("truncated".to_string(), Value::Bool(stderr.truncated));
|
||||
if let Some(total_lines) = stderr.total_lines {
|
||||
stderr_value.insert("total_lines".to_string(), Value::from(total_lines as u64));
|
||||
}
|
||||
output.insert("stderr".to_string(), Value::Object(stderr_value));
|
||||
}
|
||||
Value::Object(output)
|
||||
}
|
||||
|
||||
pub(super) fn ended_data_from_process_exit(
|
||||
status: Option<ExitStatus>,
|
||||
terminated_by: TerminatedBy,
|
||||
stderr: Option<StderrOutput>,
|
||||
) -> SessionEndedData {
|
||||
if terminated_by == TerminatedBy::Daemon {
|
||||
return SessionEndedData {
|
||||
reason: SessionEndReason::Terminated,
|
||||
terminated_by,
|
||||
message: None,
|
||||
exit_code: None,
|
||||
stderr: None,
|
||||
};
|
||||
}
|
||||
if status.as_ref().is_some_and(ExitStatus::success) {
|
||||
return SessionEndedData {
|
||||
reason: SessionEndReason::Completed,
|
||||
terminated_by,
|
||||
message: None,
|
||||
exit_code: None,
|
||||
stderr: None,
|
||||
};
|
||||
}
|
||||
let message = status
|
||||
.as_ref()
|
||||
.map(|value| format!("agent exited with status {value}"))
|
||||
.or_else(|| Some("agent exited".to_string()));
|
||||
SessionEndedData {
|
||||
reason: SessionEndReason::Error,
|
||||
terminated_by,
|
||||
message,
|
||||
exit_code: status.and_then(|value| value.code()),
|
||||
stderr,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn infer_base_url_from_launch(launch: &AgentProcessLaunchSpec) -> Option<String> {
|
||||
for (key, value) in &launch.env {
|
||||
if (key.contains("BASE_URL") || key.ends_with("_URL")) && is_http_url(value) {
|
||||
return Some(value.clone());
|
||||
}
|
||||
}
|
||||
for arg in &launch.args {
|
||||
if let Some(value) = arg.strip_prefix("--base-url=") {
|
||||
if is_http_url(value) {
|
||||
return Some(value.to_string());
|
||||
}
|
||||
}
|
||||
if let Some(value) = arg.strip_prefix("--base_url=") {
|
||||
if is_http_url(value) {
|
||||
return Some(value.to_string());
|
||||
}
|
||||
}
|
||||
if let Some(value) = arg.strip_prefix("--url=") {
|
||||
if is_http_url(value) {
|
||||
return Some(value.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut args = launch.args.iter();
|
||||
while let Some(arg) = args.next() {
|
||||
if arg == "--base-url" || arg == "--base_url" || arg == "--url" {
|
||||
if let Some(value) = args.next() {
|
||||
if is_http_url(value) {
|
||||
return Some(value.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(super) fn is_http_url(value: &str) -> bool {
|
||||
value.starts_with("http://") || value.starts_with("https://")
|
||||
}
|
||||
|
||||
pub(super) fn now_ms() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
|
@ -1,425 +0,0 @@
|
|||
use sandbox_agent_error::SandboxError;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashSet;
|
||||
use std::future::Future;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::time::sleep;
|
||||
|
||||
const MOCK_WORD_STREAM_DELAY_MS: u64 = 30;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct MockBackend {
|
||||
session_counter: Mutex<u64>,
|
||||
permission_counter: Mutex<u64>,
|
||||
sessions: Mutex<HashSet<String>>,
|
||||
ended_sessions: Mutex<HashSet<String>>,
|
||||
}
|
||||
|
||||
pub(super) fn new_mock_backend() -> MockBackend {
|
||||
MockBackend {
|
||||
session_counter: Mutex::new(0),
|
||||
permission_counter: Mutex::new(0),
|
||||
sessions: Mutex::new(HashSet::new()),
|
||||
ended_sessions: Mutex::new(HashSet::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn handle_mock_payload<F, Fut>(
|
||||
mock: &MockBackend,
|
||||
payload: &Value,
|
||||
mut emit: F,
|
||||
) -> Result<(), SandboxError>
|
||||
where
|
||||
F: FnMut(Value) -> Fut,
|
||||
Fut: Future<Output = ()>,
|
||||
{
|
||||
if let Some(method) = payload.get("method").and_then(Value::as_str) {
|
||||
let id = payload.get("id").cloned();
|
||||
let params = payload.get("params").cloned().unwrap_or(Value::Null);
|
||||
|
||||
if let Some(id_value) = id {
|
||||
let response = mock_request(mock, &mut emit, id_value, method, params).await;
|
||||
emit(response).await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
mock_notification(&mut emit, method, params).await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn mock_request<F, Fut>(
|
||||
mock: &MockBackend,
|
||||
emit: &mut F,
|
||||
id: Value,
|
||||
method: &str,
|
||||
params: Value,
|
||||
) -> Value
|
||||
where
|
||||
F: FnMut(Value) -> Fut,
|
||||
Fut: Future<Output = ()>,
|
||||
{
|
||||
match method {
|
||||
"initialize" => json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"result": {
|
||||
"protocolVersion": params
|
||||
.get("protocolVersion")
|
||||
.cloned()
|
||||
.unwrap_or(Value::String("1.0".to_string())),
|
||||
"agentCapabilities": {
|
||||
"loadSession": true,
|
||||
"promptCapabilities": {
|
||||
"image": false,
|
||||
"audio": false
|
||||
},
|
||||
"canSetMode": true,
|
||||
"canSetModel": true,
|
||||
"sessionCapabilities": {
|
||||
"list": {}
|
||||
}
|
||||
},
|
||||
"authMethods": []
|
||||
}
|
||||
}),
|
||||
"session/new" => {
|
||||
let mut counter = mock.session_counter.lock().await;
|
||||
*counter += 1;
|
||||
let session_id = format!("mock-session-{}", *counter);
|
||||
mock.sessions.lock().await.insert(session_id.clone());
|
||||
mock.ended_sessions.lock().await.remove(&session_id);
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"result": {
|
||||
"sessionId": session_id,
|
||||
"availableModes": [],
|
||||
"configOptions": []
|
||||
}
|
||||
})
|
||||
}
|
||||
"session/prompt" => {
|
||||
let known_session = {
|
||||
let sessions = mock.sessions.lock().await;
|
||||
sessions.iter().next().cloned()
|
||||
};
|
||||
let session_id = params
|
||||
.get("sessionId")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToString::to_string)
|
||||
.or(known_session)
|
||||
.unwrap_or_else(|| "mock-session-1".to_string());
|
||||
mock.sessions.lock().await.insert(session_id.clone());
|
||||
if mock.ended_sessions.lock().await.contains(&session_id) {
|
||||
return json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"error": {
|
||||
"code": -32000,
|
||||
"message": "session already ended"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let prompt_text = extract_prompt_text(¶ms);
|
||||
let response_text = prompt_text
|
||||
.clone()
|
||||
.map(|text| {
|
||||
if text.trim().is_empty() {
|
||||
"OK".to_string()
|
||||
} else {
|
||||
format!("mock: {text}")
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| "OK".to_string());
|
||||
|
||||
let requires_permission = prompt_text
|
||||
.as_deref()
|
||||
.map(|text| text.to_ascii_lowercase().contains("permission"))
|
||||
.unwrap_or(false);
|
||||
|
||||
if requires_permission {
|
||||
let mut permission_counter = mock.permission_counter.lock().await;
|
||||
*permission_counter += 1;
|
||||
let permission_id = format!("mock-permission-{}", *permission_counter);
|
||||
|
||||
emit(json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": permission_id,
|
||||
"method": "session/request_permission",
|
||||
"params": {
|
||||
"sessionId": session_id,
|
||||
"options": [
|
||||
{
|
||||
"id": "allow_once",
|
||||
"name": "Allow once"
|
||||
},
|
||||
{
|
||||
"id": "deny",
|
||||
"name": "Deny"
|
||||
}
|
||||
],
|
||||
"toolCall": {
|
||||
"toolCallId": "tool-call-1",
|
||||
"kind": "execute",
|
||||
"status": "pending",
|
||||
"rawInput": {
|
||||
"command": "echo test"
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
.await;
|
||||
}
|
||||
|
||||
let should_crash = prompt_text
|
||||
.as_deref()
|
||||
.map(|text| text.to_ascii_lowercase().contains("crash"))
|
||||
.unwrap_or(false);
|
||||
if should_crash {
|
||||
mock.ended_sessions.lock().await.insert(session_id.clone());
|
||||
emit(json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "_sandboxagent/session/ended",
|
||||
"params": {
|
||||
"session_id": session_id,
|
||||
"data": {
|
||||
"reason": "error",
|
||||
"terminated_by": "agent",
|
||||
"message": "mock process crashed",
|
||||
"exit_code": 1,
|
||||
"stderr": {
|
||||
"head": "mock stderr line 1\nmock stderr line 2",
|
||||
"truncated": false,
|
||||
"total_lines": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
.await;
|
||||
return json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"error": {
|
||||
"code": -32000,
|
||||
"message": "mock process crashed"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let word_chunks = split_text_into_word_chunks(&response_text);
|
||||
for (index, chunk) in word_chunks.iter().enumerate() {
|
||||
emit(json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "session/update",
|
||||
"params": {
|
||||
"sessionId": session_id,
|
||||
"update": {
|
||||
"sessionUpdate": "agent_message_chunk",
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": chunk
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
.await;
|
||||
|
||||
if index + 1 < word_chunks.len() {
|
||||
sleep(Duration::from_millis(MOCK_WORD_STREAM_DELAY_MS)).await;
|
||||
}
|
||||
}
|
||||
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"result": {
|
||||
"stopReason": "end_turn"
|
||||
}
|
||||
})
|
||||
}
|
||||
"session/list" => {
|
||||
let sessions = mock
|
||||
.sessions
|
||||
.lock()
|
||||
.await
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let sessions = sessions
|
||||
.into_iter()
|
||||
.map(|session_id| {
|
||||
json!({
|
||||
"sessionId": session_id,
|
||||
"cwd": "/"
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"result": {
|
||||
"sessions": sessions,
|
||||
"nextCursor": null
|
||||
}
|
||||
})
|
||||
}
|
||||
"session/fork" | "session/resume" | "session/load" => {
|
||||
let session_id = params
|
||||
.get("sessionId")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("mock-session-1")
|
||||
.to_string();
|
||||
mock.sessions.lock().await.insert(session_id.clone());
|
||||
mock.ended_sessions.lock().await.remove(&session_id);
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"result": {
|
||||
"sessionId": session_id,
|
||||
"configOptions": [],
|
||||
"availableModes": []
|
||||
}
|
||||
})
|
||||
}
|
||||
"session/set_mode" | "session/set_model" | "session/set_config_option" => json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"result": {}
|
||||
}),
|
||||
"authenticate" => json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"result": {}
|
||||
}),
|
||||
"$/cancel_request" => json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"result": {}
|
||||
}),
|
||||
"_sandboxagent/session/terminate" => {
|
||||
let fallback_session = {
|
||||
let sessions = mock.sessions.lock().await;
|
||||
sessions.iter().next().cloned()
|
||||
};
|
||||
let session_id = params
|
||||
.get("sessionId")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.or(fallback_session)
|
||||
.unwrap_or_else(|| "mock-session-1".to_string());
|
||||
let exists = mock.sessions.lock().await.contains(&session_id);
|
||||
let mut ended_sessions = mock.ended_sessions.lock().await;
|
||||
let terminated = exists && ended_sessions.insert(session_id.clone());
|
||||
drop(ended_sessions);
|
||||
if terminated {
|
||||
emit(json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "_sandboxagent/session/ended",
|
||||
"params": {
|
||||
"session_id": session_id,
|
||||
"data": {
|
||||
"reason": "terminated",
|
||||
"terminated_by": "daemon"
|
||||
}
|
||||
}
|
||||
}))
|
||||
.await;
|
||||
}
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"result": {
|
||||
"terminated": terminated,
|
||||
"alreadyEnded": !terminated,
|
||||
"reason": "terminated",
|
||||
"terminatedBy": "daemon"
|
||||
}
|
||||
})
|
||||
}
|
||||
_ => json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"result": {
|
||||
"_meta": {
|
||||
"sandboxagent.dev": {
|
||||
"mockMethod": method,
|
||||
"echoParams": params
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn mock_notification<F, Fut>(emit: &mut F, method: &str, params: Value)
|
||||
where
|
||||
F: FnMut(Value) -> Fut,
|
||||
Fut: Future<Output = ()>,
|
||||
{
|
||||
if method == "session/cancel" {
|
||||
let session_id = params
|
||||
.get("sessionId")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("mock-session-1");
|
||||
emit(json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "session/update",
|
||||
"params": {
|
||||
"sessionId": session_id,
|
||||
"update": {
|
||||
"sessionUpdate": "agent_message_chunk",
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": "cancelled"
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
fn split_text_into_word_chunks(text: &str) -> Vec<String> {
|
||||
let words: Vec<&str> = text.split_whitespace().collect();
|
||||
if words.is_empty() {
|
||||
return vec![text.to_string()];
|
||||
}
|
||||
|
||||
let last = words.len() - 1;
|
||||
words
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, word)| {
|
||||
if index == last {
|
||||
word.to_string()
|
||||
} else {
|
||||
format!("{word} ")
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn extract_prompt_text(params: &Value) -> Option<String> {
|
||||
let prompt = params.get("prompt")?.as_array()?;
|
||||
let mut output = String::new();
|
||||
for block in prompt {
|
||||
if block.get("type").and_then(Value::as_str) == Some("text") {
|
||||
if let Some(text) = block.get("text").and_then(Value::as_str) {
|
||||
if !output.is_empty() {
|
||||
output.push('\n');
|
||||
}
|
||||
output.push_str(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
if output.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(output)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,8 +1,9 @@
|
|||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command as ProcessCommand;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use clap::{Args, Parser, Subcommand};
|
||||
|
||||
|
|
@ -29,7 +30,7 @@ use thiserror::Error;
|
|||
use tower_http::cors::{Any, CorsLayer};
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
|
||||
|
||||
const API_PREFIX: &str = "/v2";
|
||||
const API_PREFIX: &str = "/v1";
|
||||
const ACP_EXTENSION_AGENT_LIST_METHOD: &str = "_sandboxagent/agent/list";
|
||||
const ACP_EXTENSION_AGENT_INSTALL_METHOD: &str = "_sandboxagent/agent/install";
|
||||
const DEFAULT_HOST: &str = "127.0.0.1";
|
||||
|
|
@ -194,7 +195,7 @@ pub struct DaemonStatusArgs {
|
|||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum ApiCommand {
|
||||
/// Manage available v2 agents and install status.
|
||||
/// Manage available v1 agents and install status.
|
||||
Agents(AgentsArgs),
|
||||
/// Send and stream raw ACP JSON-RPC envelopes.
|
||||
Acp(AcpArgs),
|
||||
|
|
@ -231,11 +232,11 @@ pub struct AcpArgs {
|
|||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum AcpCommand {
|
||||
/// Send one ACP JSON-RPC envelope to /v2/rpc.
|
||||
/// Send one ACP JSON-RPC envelope to /v1/acp/{server_id}.
|
||||
Post(AcpPostArgs),
|
||||
/// Stream ACP JSON-RPC envelopes from /v2/rpc SSE.
|
||||
/// Stream ACP JSON-RPC envelopes from /v1/acp/{server_id} SSE.
|
||||
Stream(AcpStreamArgs),
|
||||
/// Close an ACP client.
|
||||
/// Close an ACP server stream.
|
||||
Close(AcpCloseArgs),
|
||||
}
|
||||
|
||||
|
|
@ -260,22 +261,22 @@ pub struct ApiInstallAgentArgs {
|
|||
|
||||
#[derive(Args, Debug)]
|
||||
pub struct AcpPostArgs {
|
||||
#[arg(long = "client-id")]
|
||||
client_id: Option<String>,
|
||||
#[arg(long = "server-id")]
|
||||
server_id: String,
|
||||
#[arg(long = "agent")]
|
||||
agent: Option<String>,
|
||||
#[arg(long)]
|
||||
json: Option<String>,
|
||||
#[arg(long = "json-file")]
|
||||
json_file: Option<PathBuf>,
|
||||
#[arg(long, default_value_t = false)]
|
||||
print_client_id: bool,
|
||||
#[command(flatten)]
|
||||
client: ClientArgs,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
pub struct AcpStreamArgs {
|
||||
#[arg(long = "client-id")]
|
||||
client_id: String,
|
||||
#[arg(long = "server-id")]
|
||||
server_id: String,
|
||||
#[arg(long = "last-event-id")]
|
||||
last_event_id: Option<u64>,
|
||||
#[command(flatten)]
|
||||
|
|
@ -284,8 +285,8 @@ pub struct AcpStreamArgs {
|
|||
|
||||
#[derive(Args, Debug)]
|
||||
pub struct AcpCloseArgs {
|
||||
#[arg(long = "client-id")]
|
||||
client_id: String,
|
||||
#[arg(long = "server-id")]
|
||||
server_id: String,
|
||||
#[command(flatten)]
|
||||
client: ClientArgs,
|
||||
}
|
||||
|
|
@ -498,6 +499,10 @@ fn run_agents(command: &AgentsCommand, cli: &CliConfig) -> Result<(), CliError>
|
|||
}
|
||||
|
||||
fn call_acp_extension(ctx: &ClientContext, method: &str, params: Value) -> Result<Value, CliError> {
|
||||
let server_id = unique_cli_server_id("cli-ext");
|
||||
let initialize_path = build_acp_server_path(&server_id, Some("mock"))?;
|
||||
let request_path = build_acp_server_path(&server_id, None)?;
|
||||
|
||||
let initialize = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": "cli-init",
|
||||
|
|
@ -512,9 +517,13 @@ fn call_acp_extension(ctx: &ClientContext, method: &str, params: Value) -> Resul
|
|||
}
|
||||
}
|
||||
});
|
||||
let initialize_response =
|
||||
ctx.post_with_headers(&format!("{API_PREFIX}/rpc"), &initialize, &[])?;
|
||||
let connection_id = extract_connection_id(initialize_response)?;
|
||||
let initialize_response = ctx.post(&initialize_path, &initialize)?;
|
||||
let initialize_status = initialize_response.status();
|
||||
let initialize_text = initialize_response.text()?;
|
||||
if !initialize_status.is_success() {
|
||||
print_error_body(&initialize_text)?;
|
||||
return Err(CliError::HttpStatus(initialize_status));
|
||||
}
|
||||
|
||||
let request = json!({
|
||||
"jsonrpc": "2.0",
|
||||
|
|
@ -522,16 +531,9 @@ fn call_acp_extension(ctx: &ClientContext, method: &str, params: Value) -> Resul
|
|||
"method": method,
|
||||
"params": params,
|
||||
});
|
||||
let response = ctx.post_with_headers(
|
||||
&format!("{API_PREFIX}/rpc"),
|
||||
&request,
|
||||
&[("x-acp-connection-id", connection_id.as_str())],
|
||||
);
|
||||
let response = ctx.post(&request_path, &request);
|
||||
|
||||
let _ = ctx.delete_with_headers(
|
||||
&format!("{API_PREFIX}/rpc"),
|
||||
&[("x-acp-connection-id", connection_id.as_str())],
|
||||
);
|
||||
let _ = ctx.delete(&request_path);
|
||||
|
||||
let response = response?;
|
||||
let status = response.status();
|
||||
|
|
@ -553,57 +555,20 @@ fn call_acp_extension(ctx: &ClientContext, method: &str, params: Value) -> Resul
|
|||
Ok(parsed.get("result").cloned().unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
fn extract_connection_id(response: reqwest::blocking::Response) -> Result<String, CliError> {
|
||||
let status = response.status();
|
||||
let headers = response.headers().clone();
|
||||
let text = response.text()?;
|
||||
if !status.is_success() {
|
||||
print_error_body(&text)?;
|
||||
return Err(CliError::HttpStatus(status));
|
||||
}
|
||||
headers
|
||||
.get("x-acp-connection-id")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(|value| value.to_string())
|
||||
.ok_or_else(|| {
|
||||
CliError::Server("missing x-acp-connection-id in initialize response".to_string())
|
||||
})
|
||||
}
|
||||
|
||||
fn run_acp(command: &AcpCommand, cli: &CliConfig) -> Result<(), CliError> {
|
||||
match command {
|
||||
AcpCommand::Post(args) => {
|
||||
let ctx = ClientContext::new(cli, &args.client)?;
|
||||
let payload = load_json_payload(args.json.as_deref(), args.json_file.as_deref())?;
|
||||
|
||||
let mut headers = Vec::new();
|
||||
if let Some(client_id) = args.client_id.as_deref() {
|
||||
headers.push(("x-acp-connection-id", client_id));
|
||||
}
|
||||
|
||||
let response =
|
||||
ctx.post_with_headers(&format!("{API_PREFIX}/rpc"), &payload, &headers)?;
|
||||
let client_id = response
|
||||
.headers()
|
||||
.get("x-acp-connection-id")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(|value| value.to_string());
|
||||
|
||||
print_json_or_empty(response)?;
|
||||
|
||||
if args.print_client_id {
|
||||
if let Some(client_id) = client_id {
|
||||
write_stdout_line(&client_id)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
let path = build_acp_server_path(&args.server_id, args.agent.as_deref())?;
|
||||
let response = ctx.post(&path, &payload)?;
|
||||
print_json_or_empty(response)
|
||||
}
|
||||
AcpCommand::Stream(args) => {
|
||||
let ctx = ClientContext::new(cli, &args.client)?;
|
||||
let path = build_acp_server_path(&args.server_id, None)?;
|
||||
let request = ctx
|
||||
.request(Method::GET, &format!("{API_PREFIX}/rpc"))
|
||||
.header("x-acp-connection-id", args.client_id.as_str())
|
||||
.request(Method::GET, &path)
|
||||
.header("accept", "text/event-stream");
|
||||
|
||||
let request = apply_last_event_id_header(request, args.last_event_id);
|
||||
|
|
@ -613,19 +578,58 @@ fn run_acp(command: &AcpCommand, cli: &CliConfig) -> Result<(), CliError> {
|
|||
}
|
||||
AcpCommand::Close(args) => {
|
||||
let ctx = ClientContext::new(cli, &args.client)?;
|
||||
let response = ctx.delete_with_headers(
|
||||
&format!("{API_PREFIX}/rpc"),
|
||||
&[("x-acp-connection-id", args.client_id.as_str())],
|
||||
)?;
|
||||
let path = build_acp_server_path(&args.server_id, None)?;
|
||||
let response = ctx.delete(&path)?;
|
||||
print_empty_response(response)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_opencode(_cli: &CliConfig, _args: &OpencodeArgs) -> Result<(), CliError> {
|
||||
Err(CliError::Server(
|
||||
"/opencode is disabled during ACP core bring-up and will return in Phase 7".to_string(),
|
||||
))
|
||||
fn run_opencode(cli: &CliConfig, args: &OpencodeArgs) -> Result<(), CliError> {
|
||||
let token = cli.token.as_deref();
|
||||
crate::daemon::ensure_running(cli, &args.host, args.port, token)?;
|
||||
let base_url = format!("http://{}:{}", args.host, args.port);
|
||||
|
||||
let attach_url = format!("{base_url}/opencode");
|
||||
let mut attach_command = if let Ok(bin) = std::env::var("GIGACODE_OPENCODE_BIN") {
|
||||
let mut cmd = ProcessCommand::new(bin);
|
||||
cmd.arg("attach").arg(&attach_url);
|
||||
cmd
|
||||
} else {
|
||||
let mut cmd = ProcessCommand::new("opencode");
|
||||
cmd.arg("attach").arg(&attach_url);
|
||||
cmd
|
||||
};
|
||||
|
||||
let status = match attach_command.status() {
|
||||
Ok(status) => status,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||
let mut fallback = ProcessCommand::new("npx");
|
||||
fallback
|
||||
.arg("--yes")
|
||||
.arg("opencode-ai")
|
||||
.arg("attach")
|
||||
.arg(&attach_url);
|
||||
fallback.status().map_err(|fallback_err| {
|
||||
CliError::Server(format!(
|
||||
"failed to launch opencode attach. Tried `opencode attach` and `npx --yes opencode-ai attach`. Last error: {fallback_err}"
|
||||
))
|
||||
})?
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(CliError::Server(format!(
|
||||
"failed to launch opencode attach: {err}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
if !status.success() {
|
||||
return Err(CliError::Server(format!(
|
||||
"opencode attach exited with status {status}"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_daemon(command: &DaemonCommand, cli: &CliConfig) -> Result<(), CliError> {
|
||||
|
|
@ -935,6 +939,43 @@ fn apply_last_event_id_header(
|
|||
}
|
||||
}
|
||||
|
||||
fn unique_cli_server_id(prefix: &str) -> String {
|
||||
let millis = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis())
|
||||
.unwrap_or(0);
|
||||
format!("{prefix}-{}-{millis}", std::process::id())
|
||||
}
|
||||
|
||||
fn build_acp_server_path(
|
||||
server_id: &str,
|
||||
bootstrap_agent: Option<&str>,
|
||||
) -> Result<String, CliError> {
|
||||
let server_id = server_id.trim();
|
||||
if server_id.is_empty() {
|
||||
return Err(CliError::Server("server id must not be empty".to_string()));
|
||||
}
|
||||
if server_id.contains('/') {
|
||||
return Err(CliError::Server(
|
||||
"server id must not contain '/'".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut path = format!("{API_PREFIX}/acp/{server_id}");
|
||||
if let Some(agent) = bootstrap_agent {
|
||||
let agent = agent.trim();
|
||||
if agent.is_empty() {
|
||||
return Err(CliError::Server(
|
||||
"agent must not be empty when provided".to_string(),
|
||||
));
|
||||
}
|
||||
path.push_str("?agent=");
|
||||
path.push_str(agent);
|
||||
}
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn default_server_log_dir() -> PathBuf {
|
||||
if let Ok(dir) = std::env::var("SANDBOX_AGENT_LOG_DIR") {
|
||||
return PathBuf::from(dir);
|
||||
|
|
@ -1054,29 +1095,8 @@ impl ClientContext {
|
|||
Ok(self.request(Method::POST, path).json(body).send()?)
|
||||
}
|
||||
|
||||
fn post_with_headers<T: Serialize>(
|
||||
&self,
|
||||
path: &str,
|
||||
body: &T,
|
||||
headers: &[(&str, &str)],
|
||||
) -> Result<reqwest::blocking::Response, CliError> {
|
||||
let mut request = self.request(Method::POST, path).json(body);
|
||||
for (name, value) in headers {
|
||||
request = request.header(*name, *value);
|
||||
}
|
||||
Ok(request.send()?)
|
||||
}
|
||||
|
||||
fn delete_with_headers(
|
||||
&self,
|
||||
path: &str,
|
||||
headers: &[(&str, &str)],
|
||||
) -> Result<reqwest::blocking::Response, CliError> {
|
||||
let mut request = self.request(Method::DELETE, path);
|
||||
for (name, value) in headers {
|
||||
request = request.header(*name, *value);
|
||||
}
|
||||
Ok(request.send()?)
|
||||
fn delete(&self, path: &str) -> Result<reqwest::blocking::Response, CliError> {
|
||||
Ok(self.request(Method::DELETE, path).send()?)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1179,9 +1199,10 @@ mod tests {
|
|||
#[test]
|
||||
fn apply_last_event_id_header_sets_header_when_provided() {
|
||||
let client = HttpClient::builder().build().expect("build client");
|
||||
let request = apply_last_event_id_header(client.get("http://localhost/v2/rpc"), Some(42))
|
||||
.build()
|
||||
.expect("build request");
|
||||
let request =
|
||||
apply_last_event_id_header(client.get("http://localhost/v1/acp/test"), Some(42))
|
||||
.build()
|
||||
.expect("build request");
|
||||
|
||||
let header = request
|
||||
.headers()
|
||||
|
|
@ -1193,7 +1214,7 @@ mod tests {
|
|||
#[test]
|
||||
fn apply_last_event_id_header_omits_header_when_absent() {
|
||||
let client = HttpClient::builder().build().expect("build client");
|
||||
let request = apply_last_event_id_header(client.get("http://localhost/v2/rpc"), None)
|
||||
let request = apply_last_event_id_header(client.get("http://localhost/v1/acp/test"), None)
|
||||
.build()
|
||||
.expect("build request");
|
||||
assert!(request.headers().get("last-event-id").is_none());
|
||||
|
|
|
|||
|
|
@ -145,7 +145,7 @@ pub fn is_process_running(pid: u32) -> bool {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn check_health(base_url: &str, token: Option<&str>) -> Result<bool, CliError> {
|
||||
let url = format!("{base_url}/v2/health");
|
||||
let url = format!("{base_url}/v1/health");
|
||||
let started_at = Instant::now();
|
||||
let client = HttpClient::builder()
|
||||
.connect_timeout(HEALTH_CHECK_CONNECT_TIMEOUT)
|
||||
|
|
@ -205,7 +205,7 @@ pub fn wait_for_health(
|
|||
}
|
||||
}
|
||||
|
||||
let url = format!("{base_url}/v2/health");
|
||||
let url = format!("{base_url}/v1/health");
|
||||
let mut request = client.get(&url);
|
||||
if let Some(token) = token {
|
||||
request = request.bearer_auth(token);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,41 +1,4 @@
|
|||
use super::*;
|
||||
pub(super) async fn v1_removed() -> Response {
|
||||
let problem = ProblemDetails {
|
||||
type_: "urn:sandbox-agent:error:v1_removed".to_string(),
|
||||
title: "v1 API removed".to_string(),
|
||||
status: 410,
|
||||
detail: Some("v1 API removed; use /v2".to_string()),
|
||||
instance: None,
|
||||
extensions: serde_json::Map::new(),
|
||||
};
|
||||
|
||||
(
|
||||
StatusCode::GONE,
|
||||
[(header::CONTENT_TYPE, "application/problem+json")],
|
||||
Json(problem),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub(super) async fn opencode_disabled() -> Response {
|
||||
let problem = ProblemDetails {
|
||||
type_: "urn:sandbox-agent:error:opencode_disabled".to_string(),
|
||||
title: "OpenCode compatibility disabled".to_string(),
|
||||
status: 503,
|
||||
detail: Some(
|
||||
"/opencode is disabled during ACP core bring-up and will return in Phase 7".to_string(),
|
||||
),
|
||||
instance: None,
|
||||
extensions: serde_json::Map::new(),
|
||||
};
|
||||
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
[(header::CONTENT_TYPE, "application/problem+json")],
|
||||
Json(problem),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub(super) async fn not_found() -> Response {
|
||||
let problem = ProblemDetails {
|
||||
|
|
@ -79,85 +42,7 @@ pub(super) async fn require_token(
|
|||
}))
|
||||
}
|
||||
|
||||
pub(super) type PinBoxSseStream =
|
||||
std::pin::Pin<Box<dyn Stream<Item = Result<axum::response::sse::Event, Infallible>> + Send>>;
|
||||
|
||||
pub(super) fn map_runtime_session(session: crate::acp_runtime::SessionRuntimeInfo) -> SessionInfo {
|
||||
SessionInfo {
|
||||
session_id: session.session_id,
|
||||
agent: session.agent.as_str().to_string(),
|
||||
agent_mode: session
|
||||
.mode_hint
|
||||
.clone()
|
||||
.unwrap_or_else(|| "build".to_string()),
|
||||
permission_mode: session
|
||||
.sandbox_meta
|
||||
.get("permissionMode")
|
||||
.or_else(|| session.sandbox_meta.get("permission_mode"))
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| "default".to_string()),
|
||||
model: session.model_hint,
|
||||
native_session_id: session
|
||||
.sandbox_meta
|
||||
.get("nativeSessionId")
|
||||
.or_else(|| session.sandbox_meta.get("native_session_id"))
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
ended: session.ended,
|
||||
event_count: session.event_count,
|
||||
created_at: session.created_at,
|
||||
updated_at: session.updated_at,
|
||||
directory: Some(session.cwd),
|
||||
title: session.title,
|
||||
termination_info: session.ended_data.map(map_termination_info),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_termination_info(ended: crate::acp_runtime::SessionEndedData) -> TerminationInfo {
|
||||
let reason = match ended.reason {
|
||||
crate::acp_runtime::SessionEndReason::Completed => "completed",
|
||||
crate::acp_runtime::SessionEndReason::Error => "error",
|
||||
crate::acp_runtime::SessionEndReason::Terminated => "terminated",
|
||||
}
|
||||
.to_string();
|
||||
let terminated_by = match ended.terminated_by {
|
||||
crate::acp_runtime::TerminatedBy::Agent => "agent",
|
||||
crate::acp_runtime::TerminatedBy::Daemon => "daemon",
|
||||
}
|
||||
.to_string();
|
||||
TerminationInfo {
|
||||
reason,
|
||||
terminated_by,
|
||||
message: ended.message,
|
||||
exit_code: ended.exit_code,
|
||||
stderr: ended.stderr.map(|stderr| StderrOutput {
|
||||
head: stderr.head,
|
||||
tail: stderr.tail,
|
||||
truncated: stderr.truncated,
|
||||
total_lines: stderr.total_lines,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_server_status(
|
||||
status: &crate::acp_runtime::RuntimeServerStatus,
|
||||
) -> ServerStatusInfo {
|
||||
let server_status = if status.running {
|
||||
ServerStatus::Running
|
||||
} else if status.last_error.is_some() {
|
||||
ServerStatus::Error
|
||||
} else {
|
||||
ServerStatus::Stopped
|
||||
};
|
||||
ServerStatusInfo {
|
||||
status: server_status,
|
||||
base_url: status.base_url.clone(),
|
||||
uptime_ms: status.uptime_ms.map(|value| value.max(0) as u64),
|
||||
restart_count: status.restart_count,
|
||||
last_error: status.last_error.clone(),
|
||||
}
|
||||
}
|
||||
pub(super) type PinBoxSseStream = crate::acp_proxy_runtime::PinBoxSseStream;
|
||||
|
||||
pub(super) fn credentials_available_for(
|
||||
agent: AgentId,
|
||||
|
|
@ -168,10 +53,149 @@ pub(super) fn credentials_available_for(
|
|||
AgentId::Claude | AgentId::Amp => has_anthropic,
|
||||
AgentId::Codex => has_openai,
|
||||
AgentId::Opencode => has_anthropic || has_openai,
|
||||
AgentId::Pi | AgentId::Cursor => true,
|
||||
AgentId::Mock => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallback config options for agents whose ACP adapters don't return
|
||||
/// `configOptions` in `session/new`. Loaded from committed JSON resource files
|
||||
/// in `scripts/agent-configs/resources/` (generated by `scripts/agent-configs/dump.ts`).
|
||||
///
|
||||
/// To refresh: `cd scripts/agent-configs && npx tsx dump.ts`
|
||||
pub(super) fn fallback_config_options(agent: AgentId) -> Vec<Value> {
|
||||
static CLAUDE: std::sync::LazyLock<Vec<Value>> = std::sync::LazyLock::new(|| {
|
||||
parse_agent_config(include_str!(
|
||||
"../../../../../scripts/agent-configs/resources/claude.json"
|
||||
))
|
||||
});
|
||||
static CODEX: std::sync::LazyLock<Vec<Value>> = std::sync::LazyLock::new(|| {
|
||||
parse_agent_config(include_str!(
|
||||
"../../../../../scripts/agent-configs/resources/codex.json"
|
||||
))
|
||||
});
|
||||
static OPENCODE: std::sync::LazyLock<Vec<Value>> = std::sync::LazyLock::new(|| {
|
||||
parse_agent_config(include_str!(
|
||||
"../../../../../scripts/agent-configs/resources/opencode.json"
|
||||
))
|
||||
});
|
||||
static CURSOR: std::sync::LazyLock<Vec<Value>> = std::sync::LazyLock::new(|| {
|
||||
parse_agent_config(include_str!(
|
||||
"../../../../../scripts/agent-configs/resources/cursor.json"
|
||||
))
|
||||
});
|
||||
|
||||
match agent {
|
||||
AgentId::Claude => CLAUDE.clone(),
|
||||
AgentId::Codex => CODEX.clone(),
|
||||
AgentId::Opencode => OPENCODE.clone(),
|
||||
AgentId::Cursor => CURSOR.clone(),
|
||||
AgentId::Amp => vec![
|
||||
json!({
|
||||
"id": "model",
|
||||
"name": "Model",
|
||||
"category": "model",
|
||||
"type": "select",
|
||||
"currentValue": "amp-default",
|
||||
"options": [
|
||||
{ "value": "amp-default", "name": "Amp Default" }
|
||||
]
|
||||
}),
|
||||
json!({
|
||||
"id": "mode",
|
||||
"name": "Mode",
|
||||
"category": "mode",
|
||||
"type": "select",
|
||||
"currentValue": "smart",
|
||||
"options": [
|
||||
{ "value": "smart", "name": "Smart" },
|
||||
{ "value": "deep", "name": "Deep" },
|
||||
{ "value": "free", "name": "Free" },
|
||||
{ "value": "rush", "name": "Rush" }
|
||||
]
|
||||
}),
|
||||
],
|
||||
AgentId::Pi => vec![json!({
|
||||
"id": "model",
|
||||
"name": "Model",
|
||||
"category": "model",
|
||||
"type": "select",
|
||||
"currentValue": "default",
|
||||
"options": [
|
||||
{ "value": "default", "name": "Default" }
|
||||
]
|
||||
})],
|
||||
AgentId::Mock => vec![json!({
|
||||
"id": "model",
|
||||
"name": "Model",
|
||||
"category": "model",
|
||||
"type": "select",
|
||||
"currentValue": "mock",
|
||||
"options": [
|
||||
{ "value": "mock", "name": "Mock" }
|
||||
]
|
||||
})],
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse an agent config JSON file (from `scripts/agent-configs/resources/`) into
|
||||
/// ACP `SessionConfigOption` values. The JSON format is:
|
||||
/// ```json
|
||||
/// { "defaultModel": "...", "models": [{id, name}], "defaultMode?": "...", "modes?": [{id, name}] }
|
||||
/// ```
|
||||
fn parse_agent_config(json_str: &str) -> Vec<Value> {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct AgentConfig {
|
||||
#[serde(rename = "defaultModel")]
|
||||
default_model: String,
|
||||
models: Vec<ModelEntry>,
|
||||
#[serde(rename = "defaultMode")]
|
||||
default_mode: Option<String>,
|
||||
modes: Option<Vec<ModeEntry>>,
|
||||
}
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ModelEntry {
|
||||
id: String,
|
||||
name: String,
|
||||
}
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ModeEntry {
|
||||
id: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
let config: AgentConfig =
|
||||
serde_json::from_str(json_str).expect("invalid agent config JSON (compile-time resource)");
|
||||
|
||||
let mut options = vec![json!({
|
||||
"id": "model",
|
||||
"name": "Model",
|
||||
"category": "model",
|
||||
"type": "select",
|
||||
"currentValue": config.default_model,
|
||||
"options": config.models.iter().map(|m| json!({
|
||||
"value": m.id,
|
||||
"name": m.name,
|
||||
})).collect::<Vec<_>>(),
|
||||
})];
|
||||
|
||||
if let Some(modes) = config.modes {
|
||||
options.push(json!({
|
||||
"id": "mode",
|
||||
"name": "Mode",
|
||||
"category": "mode",
|
||||
"type": "select",
|
||||
"currentValue": config.default_mode.unwrap_or_else(|| modes[0].id.clone()),
|
||||
"options": modes.iter().map(|m| json!({
|
||||
"value": m.id,
|
||||
"name": m.name,
|
||||
})).collect::<Vec<_>>(),
|
||||
}));
|
||||
}
|
||||
|
||||
options
|
||||
}
|
||||
|
||||
pub(super) fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities {
|
||||
match agent {
|
||||
AgentId::Claude => AgentCapabilities {
|
||||
|
|
@ -212,7 +236,7 @@ pub(super) fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities {
|
|||
mcp_tools: true,
|
||||
streaming_deltas: true,
|
||||
item_started: true,
|
||||
shared_process: true,
|
||||
shared_process: false,
|
||||
},
|
||||
AgentId::Opencode => AgentCapabilities {
|
||||
plan_mode: false,
|
||||
|
|
@ -232,7 +256,7 @@ pub(super) fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities {
|
|||
mcp_tools: true,
|
||||
streaming_deltas: true,
|
||||
item_started: true,
|
||||
shared_process: true,
|
||||
shared_process: false,
|
||||
},
|
||||
AgentId::Amp => AgentCapabilities {
|
||||
plan_mode: false,
|
||||
|
|
@ -254,6 +278,46 @@ pub(super) fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities {
|
|||
item_started: false,
|
||||
shared_process: false,
|
||||
},
|
||||
AgentId::Pi => AgentCapabilities {
|
||||
plan_mode: false,
|
||||
permissions: false,
|
||||
questions: false,
|
||||
tool_calls: true,
|
||||
tool_results: true,
|
||||
text_messages: true,
|
||||
images: true,
|
||||
file_attachments: false,
|
||||
session_lifecycle: true,
|
||||
error_events: true,
|
||||
reasoning: false,
|
||||
status: false,
|
||||
command_execution: false,
|
||||
file_changes: false,
|
||||
mcp_tools: false,
|
||||
streaming_deltas: true,
|
||||
item_started: true,
|
||||
shared_process: false,
|
||||
},
|
||||
AgentId::Cursor => AgentCapabilities {
|
||||
plan_mode: true,
|
||||
permissions: true,
|
||||
questions: false,
|
||||
tool_calls: true,
|
||||
tool_results: true,
|
||||
text_messages: true,
|
||||
images: true,
|
||||
file_attachments: false,
|
||||
session_lifecycle: true,
|
||||
error_events: true,
|
||||
reasoning: false,
|
||||
status: false,
|
||||
command_execution: false,
|
||||
file_changes: false,
|
||||
mcp_tools: false,
|
||||
streaming_deltas: true,
|
||||
item_started: true,
|
||||
shared_process: false,
|
||||
},
|
||||
AgentId::Mock => AgentCapabilities {
|
||||
plan_mode: true,
|
||||
permissions: true,
|
||||
|
|
@ -277,124 +341,6 @@ pub(super) fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities {
|
|||
}
|
||||
}
|
||||
|
||||
pub(super) fn agent_modes_for(agent: AgentId) -> Vec<AgentModeInfo> {
|
||||
match agent {
|
||||
AgentId::Opencode => vec![
|
||||
AgentModeInfo {
|
||||
id: "build".to_string(),
|
||||
name: "Build".to_string(),
|
||||
description: "Default build mode".to_string(),
|
||||
},
|
||||
AgentModeInfo {
|
||||
id: "plan".to_string(),
|
||||
name: "Plan".to_string(),
|
||||
description: "Planning mode".to_string(),
|
||||
},
|
||||
AgentModeInfo {
|
||||
id: "custom".to_string(),
|
||||
name: "Custom".to_string(),
|
||||
description: "Any user-defined OpenCode agent name".to_string(),
|
||||
},
|
||||
],
|
||||
AgentId::Codex => vec![
|
||||
AgentModeInfo {
|
||||
id: "build".to_string(),
|
||||
name: "Build".to_string(),
|
||||
description: "Default build mode".to_string(),
|
||||
},
|
||||
AgentModeInfo {
|
||||
id: "plan".to_string(),
|
||||
name: "Plan".to_string(),
|
||||
description: "Planning mode via prompt prefix".to_string(),
|
||||
},
|
||||
],
|
||||
AgentId::Claude => vec![
|
||||
AgentModeInfo {
|
||||
id: "build".to_string(),
|
||||
name: "Build".to_string(),
|
||||
description: "Default build mode".to_string(),
|
||||
},
|
||||
AgentModeInfo {
|
||||
id: "plan".to_string(),
|
||||
name: "Plan".to_string(),
|
||||
description: "Plan mode (prompt-only)".to_string(),
|
||||
},
|
||||
],
|
||||
AgentId::Amp => vec![AgentModeInfo {
|
||||
id: "build".to_string(),
|
||||
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(),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn fallback_models_for_agent(
|
||||
agent: AgentId,
|
||||
) -> Option<(Vec<AgentModelInfo>, Option<String>)> {
|
||||
match agent {
|
||||
AgentId::Claude => Some((
|
||||
vec![
|
||||
AgentModelInfo {
|
||||
id: "default".to_string(),
|
||||
name: Some("Default (recommended)".to_string()),
|
||||
variants: None,
|
||||
default_variant: None,
|
||||
},
|
||||
AgentModelInfo {
|
||||
id: "sonnet".to_string(),
|
||||
name: Some("Sonnet".to_string()),
|
||||
variants: None,
|
||||
default_variant: None,
|
||||
},
|
||||
AgentModelInfo {
|
||||
id: "opus".to_string(),
|
||||
name: Some("Opus".to_string()),
|
||||
variants: None,
|
||||
default_variant: None,
|
||||
},
|
||||
AgentModelInfo {
|
||||
id: "haiku".to_string(),
|
||||
name: Some("Haiku".to_string()),
|
||||
variants: None,
|
||||
default_variant: None,
|
||||
},
|
||||
],
|
||||
Some("default".to_string()),
|
||||
)),
|
||||
AgentId::Amp => Some((
|
||||
vec![AgentModelInfo {
|
||||
id: "amp-default".to_string(),
|
||||
name: Some("Amp Default".to_string()),
|
||||
variants: None,
|
||||
default_variant: None,
|
||||
}],
|
||||
Some("amp-default".to_string()),
|
||||
)),
|
||||
AgentId::Mock => Some((
|
||||
vec![AgentModelInfo {
|
||||
id: "mock".to_string(),
|
||||
name: Some("Mock".to_string()),
|
||||
variants: None,
|
||||
default_variant: None,
|
||||
}],
|
||||
Some("mock".to_string()),
|
||||
)),
|
||||
AgentId::Codex | AgentId::Opencode => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_install_result(result: InstallResult) -> AgentInstallResponse {
|
||||
AgentInstallResponse {
|
||||
already_installed: result.already_installed,
|
||||
|
|
@ -429,41 +375,21 @@ pub(super) fn map_artifact_kind(kind: InstalledArtifactKind) -> String {
|
|||
.to_string()
|
||||
}
|
||||
|
||||
pub(super) async fn resolve_fs_path(
|
||||
state: &Arc<AppState>,
|
||||
session_id: Option<&str>,
|
||||
raw_path: &str,
|
||||
) -> Result<PathBuf, SandboxError> {
|
||||
pub(super) fn resolve_fs_path(raw_path: &str) -> Result<PathBuf, SandboxError> {
|
||||
let path = PathBuf::from(raw_path);
|
||||
if path.is_absolute() {
|
||||
return Ok(path);
|
||||
}
|
||||
let root = resolve_fs_root(state, session_id).await?;
|
||||
let relative = sanitize_relative_path(&path)?;
|
||||
Ok(root.join(relative))
|
||||
}
|
||||
|
||||
pub(super) async fn resolve_fs_root(
|
||||
state: &Arc<AppState>,
|
||||
session_id: Option<&str>,
|
||||
) -> Result<PathBuf, SandboxError> {
|
||||
if let Some(session_id) = session_id {
|
||||
let session = state
|
||||
.acp_runtime()
|
||||
.get_session(session_id)
|
||||
.await
|
||||
.ok_or_else(|| SandboxError::SessionNotFound {
|
||||
session_id: session_id.to_string(),
|
||||
})?;
|
||||
return Ok(PathBuf::from(session.cwd));
|
||||
}
|
||||
let home = std::env::var_os("HOME")
|
||||
.or_else(|| std::env::var_os("USERPROFILE"))
|
||||
.map(PathBuf::from)
|
||||
.ok_or_else(|| SandboxError::InvalidRequest {
|
||||
message: "home directory unavailable".to_string(),
|
||||
})?;
|
||||
Ok(home)
|
||||
|
||||
let relative = sanitize_relative_path(&path)?;
|
||||
Ok(home.join(relative))
|
||||
}
|
||||
|
||||
pub(super) fn sanitize_relative_path(path: &StdPath) -> Result<PathBuf, SandboxError> {
|
||||
|
|
@ -495,14 +421,6 @@ pub(super) fn map_fs_error(path: &StdPath, err: std::io::Error) -> SandboxError
|
|||
}
|
||||
}
|
||||
|
||||
pub(super) fn header_str(headers: &HeaderMap, name: &str) -> Option<String> {
|
||||
headers
|
||||
.get(name)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
pub(super) fn content_type_is(headers: &HeaderMap, expected: &str) -> bool {
|
||||
let Some(value) = headers
|
||||
.get(header::CONTENT_TYPE)
|
||||
|
|
@ -575,44 +493,10 @@ pub(super) fn parse_last_event_id(headers: &HeaderMap) -> Result<Option<u64>, Sa
|
|||
}
|
||||
}
|
||||
|
||||
pub(super) fn set_client_id_header(
|
||||
response: &mut Response,
|
||||
client_id: &str,
|
||||
) -> Result<(), ApiError> {
|
||||
let header_value = HeaderValue::from_str(client_id).map_err(|err| {
|
||||
ApiError::Sandbox(SandboxError::StreamError {
|
||||
message: format!("invalid client id header value: {err}"),
|
||||
})
|
||||
})?;
|
||||
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(ACP_CLIENT_HEADER, header_value);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn request_principal(state: &AppState, headers: &HeaderMap) -> String {
|
||||
if state.auth.token.is_some() {
|
||||
headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| "authenticated".to_string())
|
||||
} else {
|
||||
"anonymous".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn problem_from_sandbox_error(error: &SandboxError) -> ProblemDetails {
|
||||
let mut problem = error.to_problem_details();
|
||||
|
||||
match error {
|
||||
SandboxError::SessionNotFound { .. } => {
|
||||
problem.type_ = "urn:sandbox-agent:error:client_not_found".to_string();
|
||||
problem.title = "ACP client not found".to_string();
|
||||
problem.detail = Some("unknown ACP client id".to_string());
|
||||
problem.status = 404;
|
||||
}
|
||||
SandboxError::InvalidRequest { .. } => {
|
||||
problem.status = 400;
|
||||
}
|
||||
|
|
@ -624,3 +508,112 @@ pub(super) fn problem_from_sandbox_error(error: &SandboxError) -> ProblemDetails
|
|||
|
||||
problem
|
||||
}
|
||||
|
||||
/// Build the OpenCode-compatible provider payload from installed agent config
|
||||
/// options. This replaces the hardcoded mock/amp/claude/codex list in the
|
||||
/// opencode-adapter with real model information derived from
|
||||
/// `fallback_config_options()`.
|
||||
pub(super) fn build_provider_payload_for_opencode(_state: &Arc<AppState>) -> Value {
|
||||
let agents: &[AgentId] = &[
|
||||
AgentId::Mock,
|
||||
AgentId::Claude,
|
||||
AgentId::Codex,
|
||||
AgentId::Amp,
|
||||
AgentId::Opencode,
|
||||
AgentId::Pi,
|
||||
AgentId::Cursor,
|
||||
];
|
||||
|
||||
let has_anthropic = std::env::var("ANTHROPIC_API_KEY").is_ok();
|
||||
let has_openai = std::env::var("OPENAI_API_KEY").is_ok();
|
||||
|
||||
let mut all_providers = Vec::new();
|
||||
let mut defaults = serde_json::Map::new();
|
||||
let mut connected = Vec::new();
|
||||
|
||||
for &agent in agents {
|
||||
let agent_str = agent.as_str();
|
||||
|
||||
let options = fallback_config_options(agent);
|
||||
let model_option = options
|
||||
.iter()
|
||||
.find(|opt| opt.get("category").and_then(Value::as_str) == Some("model"));
|
||||
|
||||
let Some(model_option) = model_option else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let current_value = model_option
|
||||
.get("currentValue")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("default");
|
||||
let option_list = model_option
|
||||
.get("options")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut models = serde_json::Map::new();
|
||||
for opt in &option_list {
|
||||
let id = opt
|
||||
.get("value")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("default");
|
||||
let name = opt.get("name").and_then(Value::as_str).unwrap_or(id);
|
||||
models.insert(
|
||||
id.to_string(),
|
||||
json!({
|
||||
"id": id,
|
||||
"name": name,
|
||||
"family": capitalize_first(agent_str),
|
||||
"release_date": "1970-01-01",
|
||||
"attachment": false,
|
||||
"reasoning": false,
|
||||
"temperature": true,
|
||||
"tool_call": true,
|
||||
"limit": { "context": 200_000, "output": 8_192 },
|
||||
"options": {},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
defaults.insert(agent_str.to_string(), json!(current_value));
|
||||
|
||||
if agent == AgentId::Mock || credentials_available_for(agent, has_anthropic, has_openai) {
|
||||
connected.push(json!(agent_str));
|
||||
}
|
||||
|
||||
all_providers.push(json!({
|
||||
"id": agent_str,
|
||||
"name": agent_display_name(agent),
|
||||
"env": [],
|
||||
"models": Value::Object(models),
|
||||
}));
|
||||
}
|
||||
|
||||
json!({
|
||||
"all": all_providers,
|
||||
"default": Value::Object(defaults),
|
||||
"connected": connected,
|
||||
})
|
||||
}
|
||||
|
||||
fn agent_display_name(agent: AgentId) -> &'static str {
|
||||
match agent {
|
||||
AgentId::Mock => "Mock",
|
||||
AgentId::Claude => "Claude Code",
|
||||
AgentId::Codex => "Codex CLI",
|
||||
AgentId::Amp => "Amp",
|
||||
AgentId::Opencode => "OpenCode",
|
||||
AgentId::Pi => "Pi",
|
||||
AgentId::Cursor => "Cursor Agent",
|
||||
}
|
||||
}
|
||||
|
||||
fn capitalize_first(s: &str) -> String {
|
||||
let mut chars = s.chars();
|
||||
match chars.next() {
|
||||
None => String::new(),
|
||||
Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,43 +1,17 @@
|
|||
use std::collections::BTreeMap;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
pub struct HealthResponse {
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentModeInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentModelInfo {
|
||||
pub id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub variants: Option<Vec<String>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub default_variant: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentModelsResponse {
|
||||
pub models: Vec<AgentModelInfo>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub default_model: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ServerStatus {
|
||||
Running,
|
||||
Stopped,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
|
|
@ -45,12 +19,7 @@ pub enum ServerStatus {
|
|||
pub struct ServerStatusInfo {
|
||||
pub status: ServerStatus,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub base_url: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub uptime_ms: Option<u64>,
|
||||
pub restart_count: u64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
|
|
@ -90,11 +59,9 @@ pub struct AgentInfo {
|
|||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub server_status: Option<ServerStatusInfo>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub models: Option<Vec<AgentModelInfo>>,
|
||||
pub config_options: Option<Vec<Value>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub default_model: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub modes: Option<Vec<AgentModeInfo>>,
|
||||
pub config_error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
|
|
@ -103,6 +70,14 @@ pub struct AgentListResponse {
|
|||
pub agents: Vec<AgentInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, JsonSchema)]
|
||||
pub struct AgentsQuery {
|
||||
#[serde(default)]
|
||||
pub config: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub no_cache: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentInstallRequest {
|
||||
|
|
@ -125,110 +100,10 @@ pub struct AgentInstallResponse {
|
|||
pub artifacts: Vec<AgentInstallArtifact>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StderrOutput {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub head: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tail: Option<String>,
|
||||
pub truncated: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub total_lines: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TerminationInfo {
|
||||
pub reason: String,
|
||||
pub terminated_by: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub message: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub exit_code: Option<i32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub stderr: Option<StderrOutput>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionInfo {
|
||||
pub session_id: String,
|
||||
pub agent: String,
|
||||
pub agent_mode: String,
|
||||
pub permission_mode: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub native_session_id: Option<String>,
|
||||
pub ended: bool,
|
||||
pub event_count: u64,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub directory: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub termination_info: Option<TerminationInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
pub struct SessionListResponse {
|
||||
pub sessions: Vec<SessionInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateSessionRequest {
|
||||
pub agent: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub agent_mode: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub permission_mode: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub variant: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub agent_version: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub directory: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub mcp: Option<Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub skills: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PermissionReply {
|
||||
Once,
|
||||
Always,
|
||||
Reject,
|
||||
}
|
||||
|
||||
impl std::str::FromStr for PermissionReply {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
match value.to_ascii_lowercase().as_str() {
|
||||
"once" => Ok(Self::Once),
|
||||
"always" => Ok(Self::Always),
|
||||
"reject" => Ok(Self::Reject),
|
||||
_ => Err(format!("invalid permission reply: {value}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FsPathQuery {
|
||||
pub path: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none", alias = "session_id")]
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
|
|
@ -236,23 +111,12 @@ pub struct FsPathQuery {
|
|||
pub struct FsEntriesQuery {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none", alias = "session_id")]
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FsSessionQuery {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none", alias = "session_id")]
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FsDeleteQuery {
|
||||
pub path: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none", alias = "session_id")]
|
||||
pub session_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub recursive: Option<bool>,
|
||||
}
|
||||
|
|
@ -262,8 +126,6 @@ pub struct FsDeleteQuery {
|
|||
pub struct FsUploadBatchQuery {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none", alias = "session_id")]
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
|
|
@ -330,6 +192,162 @@ pub struct FsUploadBatchResponse {
|
|||
pub truncated: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AcpPostQuery {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub agent: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AcpServerInfo {
|
||||
pub server_id: String,
|
||||
pub agent: String,
|
||||
pub created_at_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AcpServerListResponse {
|
||||
pub servers: Vec<AcpServerInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct McpConfigQuery {
|
||||
pub directory: String,
|
||||
#[serde(rename = "mcpName", alias = "mcp_name")]
|
||||
pub mcp_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SkillsConfigQuery {
|
||||
pub directory: String,
|
||||
#[serde(rename = "skillName", alias = "skill_name")]
|
||||
pub skill_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SkillsConfig {
|
||||
pub sources: Vec<SkillSource>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SkillSource {
|
||||
#[serde(rename = "type")]
|
||||
pub source_type: String,
|
||||
pub source: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub skills: Option<Vec<String>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none", rename = "ref")]
|
||||
pub git_ref: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub subpath: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum McpCommand {
|
||||
Command(String),
|
||||
CommandWithArgs(Vec<String>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum McpRemoteTransport {
|
||||
Http,
|
||||
Sse,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct McpOAuthConfig {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub client_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub client_secret: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub scope: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum McpOAuthConfigOrDisabled {
|
||||
Config(McpOAuthConfig),
|
||||
Disabled(bool),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
#[serde(rename_all = "camelCase", tag = "type")]
|
||||
pub enum McpServerConfig {
|
||||
#[serde(rename = "local", alias = "stdio")]
|
||||
Local {
|
||||
command: McpCommand,
|
||||
#[serde(default)]
|
||||
args: Vec<String>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
alias = "environment"
|
||||
)]
|
||||
env: Option<BTreeMap<String, String>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
enabled: Option<bool>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
rename = "timeoutMs",
|
||||
alias = "timeout"
|
||||
)]
|
||||
#[schema(rename = "timeoutMs")]
|
||||
timeout_ms: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
cwd: Option<String>,
|
||||
},
|
||||
#[serde(rename = "remote", alias = "http")]
|
||||
Remote {
|
||||
url: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
headers: Option<BTreeMap<String, String>>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
rename = "bearerTokenEnvVar",
|
||||
alias = "bearerTokenEnvVar",
|
||||
alias = "bearer_token_env_var"
|
||||
)]
|
||||
#[schema(rename = "bearerTokenEnvVar")]
|
||||
bearer_token_env_var: Option<String>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
rename = "envHeaders",
|
||||
alias = "envHttpHeaders",
|
||||
alias = "env_http_headers"
|
||||
)]
|
||||
#[schema(rename = "envHeaders")]
|
||||
env_headers: Option<BTreeMap<String, String>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
oauth: Option<McpOAuthConfigOrDisabled>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
enabled: Option<bool>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
rename = "timeoutMs",
|
||||
alias = "timeout"
|
||||
)]
|
||||
#[schema(rename = "timeoutMs")]
|
||||
timeout_ms: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
transport: Option<McpRemoteTransport>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
pub struct AcpEnvelope {
|
||||
pub jsonrpc: String,
|
||||
|
|
|
|||
|
|
@ -5,10 +5,11 @@
|
|||
|
||||
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 { existsSync, mkdtempSync, rmSync, appendFileSync } from "node:fs";
|
||||
import { resolve, dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
|
|
@ -79,7 +80,7 @@ async function waitForHealth(
|
|||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/v2/health`, {
|
||||
const response = await fetch(`${baseUrl}/v1/health`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (response.ok) {
|
||||
|
|
@ -140,6 +141,8 @@ export async function spawnSandboxAgent(options: SpawnOptions = {}): Promise<San
|
|||
const timeoutMs = options.timeoutMs ?? 30_000;
|
||||
|
||||
const args = ["server", "--host", host, "--port", String(port), "--token", token];
|
||||
const tempStateDir = mkdtempSync(join(tmpdir(), "sandbox-agent-opencode-"));
|
||||
const sqlitePath = join(tempStateDir, "opencode-sessions.db");
|
||||
|
||||
const compatEnv = {
|
||||
OPENCODE_COMPAT_FIXED_TIME_MS: "1700000000000",
|
||||
|
|
@ -149,6 +152,7 @@ export async function spawnSandboxAgent(options: SpawnOptions = {}): Promise<San
|
|||
OPENCODE_COMPAT_STATE: "/state/opencode",
|
||||
OPENCODE_COMPAT_CONFIG: "/config/opencode",
|
||||
OPENCODE_COMPAT_BRANCH: "main",
|
||||
OPENCODE_COMPAT_DB_PATH: sqlitePath,
|
||||
};
|
||||
|
||||
const child = spawn(binaryPath, args, {
|
||||
|
|
@ -162,18 +166,21 @@ export async function spawnSandboxAgent(options: SpawnOptions = {}): Promise<San
|
|||
|
||||
// Collect stderr for debugging
|
||||
let stderr = "";
|
||||
const logFile = process.env.SANDBOX_AGENT_TEST_LOG_FILE;
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
const text = chunk.toString();
|
||||
stderr += text;
|
||||
if (logFile) appendFileSync(logFile, `[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) => {
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
if (logFile) appendFileSync(logFile, `[stdout] ${chunk.toString()}`);
|
||||
if (process.env.SANDBOX_AGENT_TEST_LOGS) {
|
||||
process.stderr.write(chunk.toString());
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const baseUrl = `http://${host}:${port}`;
|
||||
|
||||
|
|
@ -189,6 +196,7 @@ export async function spawnSandboxAgent(options: SpawnOptions = {}): Promise<San
|
|||
|
||||
const dispose = async () => {
|
||||
if (child.exitCode !== null) {
|
||||
rmSync(tempStateDir, { recursive: true, force: true });
|
||||
return;
|
||||
}
|
||||
child.kill("SIGTERM");
|
||||
|
|
@ -196,6 +204,7 @@ export async function spawnSandboxAgent(options: SpawnOptions = {}): Promise<San
|
|||
if (!exited) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
rmSync(tempStateDir, { recursive: true, force: true });
|
||||
};
|
||||
|
||||
return { baseUrl, token, child, dispose };
|
||||
|
|
|
|||
|
|
@ -44,12 +44,12 @@ describe("OpenCode-compatible Model API", () => {
|
|||
expect(mockModels["mock"].family).toBe("Mock");
|
||||
|
||||
const ampModels = ampProvider?.models ?? {};
|
||||
expect(ampModels["smart"]).toBeDefined();
|
||||
expect(ampModels["smart"].id).toBe("smart");
|
||||
expect(ampModels["smart"].family).toBe("Amp");
|
||||
expect(ampModels["amp-default"]).toBeDefined();
|
||||
expect(ampModels["amp-default"].id).toBe("amp-default");
|
||||
expect(ampModels["amp-default"].family).toBe("Amp");
|
||||
|
||||
expect(response.data?.default?.["mock"]).toBe("mock");
|
||||
expect(response.data?.default?.["amp"]).toBe("smart");
|
||||
expect(response.data?.default?.["amp"]).toBe("amp-default");
|
||||
});
|
||||
|
||||
it("should keep provider backends visible when discovery is degraded", async () => {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest";
|
||||
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2";
|
||||
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v1";
|
||||
import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn";
|
||||
|
||||
describe("OpenCode-compatible Permission API", () => {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest";
|
||||
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2";
|
||||
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v1";
|
||||
import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn";
|
||||
|
||||
describe("OpenCode-compatible Question API", () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,244 @@
|
|||
/**
|
||||
* Integration test for real agent prompts via the OpenCode compatibility layer.
|
||||
*
|
||||
* Skipped unless TEST_AGENT_MODEL is set. Example:
|
||||
*
|
||||
* TEST_AGENT_MODEL=gpt-5.2-codex npx vitest run server/packages/sandbox-agent/tests/opencode-compat/real-agent.test.ts
|
||||
*
|
||||
* The env var value is a model ID. Provider is inferred:
|
||||
* - gpt-* → codex
|
||||
* - claude-* → claude
|
||||
* - amp-* → amp
|
||||
* - foo/bar → opencode (slash means opencode provider passthrough)
|
||||
* - otherwise → mock
|
||||
*/
|
||||
|
||||
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";
|
||||
|
||||
const MODEL = process.env.TEST_AGENT_MODEL;
|
||||
|
||||
function inferProvider(model: string): string {
|
||||
if (model.startsWith("gpt-")) return "codex";
|
||||
if (model.startsWith("claude-")) return "claude";
|
||||
if (model.startsWith("amp-")) return "amp";
|
||||
if (model.includes("/")) return "opencode";
|
||||
return "mock";
|
||||
}
|
||||
|
||||
describe.skipIf(!MODEL)("Real agent round-trip", () => {
|
||||
let handle: SandboxAgentHandle;
|
||||
let client: OpencodeClient;
|
||||
|
||||
const modelId = MODEL ?? "mock";
|
||||
const providerId = process.env.TEST_AGENT_PROVIDER ?? inferProvider(modelId);
|
||||
|
||||
beforeAll(async () => {
|
||||
await buildSandboxAgent();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
handle = await spawnSandboxAgent({
|
||||
opencodeCompat: true,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
client = createOpencodeClient({
|
||||
baseUrl: `${handle.baseUrl}/opencode`,
|
||||
headers: { Authorization: `Bearer ${handle.token}` },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await handle?.dispose();
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper: wait for the next session.idle on the event stream, collecting text.
|
||||
* Uses a manual iterator to avoid closing the stream (for-await-of calls
|
||||
* iterator.return() on early exit, which would close the SSE connection).
|
||||
*/
|
||||
function collectUntilIdle(
|
||||
iter: AsyncIterator<any>,
|
||||
timeoutMs = 30_000,
|
||||
): Promise<{ events: any[]; text: string }> {
|
||||
const events: any[] = [];
|
||||
let text = "";
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new Error(
|
||||
`Timed out after ${timeoutMs}ms. Events: ${JSON.stringify(events.map((e) => e.type))}`,
|
||||
),
|
||||
),
|
||||
timeoutMs,
|
||||
);
|
||||
(async () => {
|
||||
try {
|
||||
while (true) {
|
||||
const { value: event, done } = await iter.next();
|
||||
if (done) {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error("Stream ended before session.idle"));
|
||||
return;
|
||||
}
|
||||
events.push(event);
|
||||
if (
|
||||
event.type === "message.part.updated" &&
|
||||
event.properties?.part?.type === "text"
|
||||
) {
|
||||
// Prefer the delta (chunk) if present; otherwise use the full
|
||||
// accumulated part.text (for non-streaming single-shot events).
|
||||
text += event.properties.delta ?? event.properties.part.text ?? "";
|
||||
}
|
||||
if (event.type === "session.idle") {
|
||||
clearTimeout(timeout);
|
||||
resolve({ events, text });
|
||||
return;
|
||||
}
|
||||
if (event.type === "session.error") {
|
||||
clearTimeout(timeout);
|
||||
reject(
|
||||
new Error(
|
||||
`session.error: ${JSON.stringify(event.properties?.error)}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
}
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
it(`should get a response from ${MODEL}`, async () => {
|
||||
const session = await client.session.create();
|
||||
const sessionId = session.data?.id!;
|
||||
expect(sessionId).toBeDefined();
|
||||
|
||||
const eventStream = await client.event.subscribe();
|
||||
const stream = (eventStream as any).stream as AsyncIterable<any>;
|
||||
const iter = stream[Symbol.asyncIterator]();
|
||||
|
||||
// Start collecting BEFORE sending prompt so no events are lost
|
||||
const turn1Promise = collectUntilIdle(iter);
|
||||
|
||||
const prompt = await client.session.prompt({
|
||||
path: { id: sessionId },
|
||||
body: {
|
||||
model: { providerID: providerId, modelID: modelId },
|
||||
parts: [{ type: "text", text: "Reply with exactly: hello world" }],
|
||||
},
|
||||
});
|
||||
expect(prompt.error).toBeUndefined();
|
||||
|
||||
const turn1 = await turn1Promise;
|
||||
console.log(`Turn 1 — events: ${turn1.events.length}, text: ${turn1.text.slice(0, 200)}`);
|
||||
expect(turn1.text.length).toBeGreaterThan(0);
|
||||
}, 60_000);
|
||||
|
||||
it(`should have correct message ordering and info`, async () => {
|
||||
const session = await client.session.create();
|
||||
const sessionId = session.data?.id!;
|
||||
expect(sessionId).toBeDefined();
|
||||
|
||||
const eventStream = await client.event.subscribe();
|
||||
const stream = (eventStream as any).stream as AsyncIterable<any>;
|
||||
const iter = stream[Symbol.asyncIterator]();
|
||||
|
||||
const turnPromise = collectUntilIdle(iter);
|
||||
await client.session.prompt({
|
||||
path: { id: sessionId },
|
||||
body: {
|
||||
model: { providerID: providerId, modelID: modelId },
|
||||
parts: [{ type: "text", text: "Reply with exactly: test" }],
|
||||
},
|
||||
});
|
||||
const turn = await turnPromise;
|
||||
|
||||
// ── Verify SSE event ordering ──
|
||||
const msgUpdates = turn.events.filter((e: any) => e.type === "message.updated");
|
||||
console.log("SSE message.updated events in order:");
|
||||
for (const e of msgUpdates) {
|
||||
const info = e.properties?.info ?? {};
|
||||
console.log(` role=${info.role ?? "?"} id=${info.id ?? "?"} parentID=${info.parentID ?? "none"} time=${JSON.stringify(info.time)}`);
|
||||
}
|
||||
// user message.updated must come before assistant message.updated in the event stream
|
||||
const userUpdateIdx = msgUpdates.findIndex((e: any) => e.properties?.info?.role === "user");
|
||||
const assistantUpdateIdx = msgUpdates.findIndex((e: any) => e.properties?.info?.role === "assistant");
|
||||
expect(userUpdateIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(assistantUpdateIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(userUpdateIdx).toBeLessThan(assistantUpdateIdx);
|
||||
|
||||
// ── Verify persisted messages via HTTP ──
|
||||
const res = await fetch(`${handle.baseUrl}/opencode/session/${sessionId}/message`, {
|
||||
headers: { Authorization: `Bearer ${handle.token}` },
|
||||
});
|
||||
const messages: any[] = await res.json();
|
||||
expect(messages.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const user = messages.find((m: any) => m.info?.role === "user");
|
||||
const assistant = messages.find((m: any) => m.info?.role === "assistant");
|
||||
expect(user).toBeDefined();
|
||||
expect(assistant).toBeDefined();
|
||||
|
||||
// Assistant must reference user via parentID
|
||||
expect(assistant.info.parentID).toBe(user.info.id);
|
||||
expect(assistant.info.role).toBe("assistant");
|
||||
expect(assistant.info.id).toBeDefined();
|
||||
expect(assistant.info.id).not.toBe("");
|
||||
|
||||
// User must appear before assistant in the persisted array
|
||||
const userIdx = messages.indexOf(user);
|
||||
const assistantIdx = messages.indexOf(assistant);
|
||||
expect(userIdx).toBeLessThan(assistantIdx);
|
||||
|
||||
console.log(`Persisted: user=${user.info.id}, assistant=${assistant.info.id}, parentID=${assistant.info.parentID}`);
|
||||
}, 60_000);
|
||||
|
||||
it(`should handle multi-turn conversation with ${MODEL}`, async () => {
|
||||
const session = await client.session.create();
|
||||
const sessionId = session.data?.id!;
|
||||
expect(sessionId).toBeDefined();
|
||||
|
||||
const eventStream = await client.event.subscribe();
|
||||
const stream = (eventStream as any).stream as AsyncIterable<any>;
|
||||
const iter = stream[Symbol.asyncIterator]();
|
||||
|
||||
// Turn 1 — start collecting BEFORE prompt
|
||||
const turn1Promise = collectUntilIdle(iter);
|
||||
const p1 = await client.session.prompt({
|
||||
path: { id: sessionId },
|
||||
body: {
|
||||
model: { providerID: providerId, modelID: modelId },
|
||||
parts: [{ type: "text", text: "Remember the number 42. Reply with just: ok" }],
|
||||
},
|
||||
});
|
||||
expect(p1.error).toBeUndefined();
|
||||
const turn1 = await turn1Promise;
|
||||
console.log(`Turn 1 — events: ${turn1.events.length}, text: ${turn1.text.slice(0, 200)}`);
|
||||
expect(turn1.text.length).toBeGreaterThan(0);
|
||||
|
||||
// Turn 2 — start collecting BEFORE prompt
|
||||
const turn2Promise = collectUntilIdle(iter);
|
||||
const p2 = await client.session.prompt({
|
||||
path: { id: sessionId },
|
||||
body: {
|
||||
parts: [{ type: "text", text: "What number did I ask you to remember? Reply with just the number." }],
|
||||
},
|
||||
});
|
||||
expect(p2.error).toBeUndefined();
|
||||
const turn2 = await turn2Promise;
|
||||
console.log(`Turn 2 — events: ${turn2.events.length}, text: ${turn2.text.slice(0, 200)}`);
|
||||
expect(turn2.text.length).toBeGreaterThan(0);
|
||||
expect(turn2.text).toContain("42");
|
||||
}, 90_000);
|
||||
});
|
||||
|
|
@ -15,6 +15,9 @@
|
|||
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";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
describe("OpenCode-compatible Session API", () => {
|
||||
let handle: SandboxAgentHandle;
|
||||
|
|
@ -276,6 +279,47 @@ describe("OpenCode-compatible Session API", () => {
|
|||
});
|
||||
expect(prompt.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should reject init model changes after the first prompt", async () => {
|
||||
const session = await client.session.create();
|
||||
const sessionId = session.data?.id!;
|
||||
expect(sessionId).toBeDefined();
|
||||
|
||||
const firstPrompt = await client.session.prompt({
|
||||
path: { id: sessionId },
|
||||
body: {
|
||||
model: { providerID: "mock", modelID: "mock" },
|
||||
parts: [{ type: "text", text: "first" }],
|
||||
},
|
||||
});
|
||||
expect(firstPrompt.error).toBeUndefined();
|
||||
|
||||
const changed = await initSessionViaHttp(sessionId, {
|
||||
providerID: "codex",
|
||||
modelID: "gpt-5",
|
||||
});
|
||||
expect(changed.response.status).toBe(400);
|
||||
expect(changed.data?.errors?.[0]?.message).toBe(
|
||||
"OpenCode compatibility currently does not support changing the model after creating a session. Export with /export and load in to a new session."
|
||||
);
|
||||
});
|
||||
|
||||
it("should map agent-only first prompt selection to provider/model defaults", async () => {
|
||||
const session = await client.session.create();
|
||||
const sessionId = session.data?.id!;
|
||||
expect(sessionId).toBeDefined();
|
||||
|
||||
const prompt = await client.session.prompt({
|
||||
path: { id: sessionId },
|
||||
body: {
|
||||
agent: "codex",
|
||||
parts: [{ type: "text", text: "hello with agent only" }],
|
||||
} as any,
|
||||
});
|
||||
expect(prompt.error).toBeUndefined();
|
||||
expect(prompt.data?.info?.providerID).toBe("codex");
|
||||
expect(prompt.data?.info?.modelID).toBe("gpt-5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("session.get", () => {
|
||||
|
|
@ -333,6 +377,68 @@ describe("OpenCode-compatible Session API", () => {
|
|||
|
||||
expect(response.error).toBeDefined();
|
||||
});
|
||||
|
||||
it("should restore persisted sessions after server restart and continue prompting", async () => {
|
||||
await handle.dispose();
|
||||
|
||||
const tempStateDir = mkdtempSync(join(tmpdir(), "sandbox-agent-opencode-restore-"));
|
||||
const sqlitePath = join(tempStateDir, "opencode-sessions.db");
|
||||
|
||||
try {
|
||||
handle = await spawnSandboxAgent({
|
||||
opencodeCompat: true,
|
||||
env: { OPENCODE_COMPAT_DB_PATH: sqlitePath },
|
||||
});
|
||||
client = createOpencodeClient({
|
||||
baseUrl: `${handle.baseUrl}/opencode`,
|
||||
headers: { Authorization: `Bearer ${handle.token}` },
|
||||
});
|
||||
|
||||
const created = await client.session.create({ body: { title: "Persisted Session" } });
|
||||
const sessionId = created.data?.id!;
|
||||
expect(sessionId).toBeDefined();
|
||||
|
||||
const firstPrompt = await client.session.prompt({
|
||||
path: { id: sessionId },
|
||||
body: {
|
||||
model: { providerID: "mock", modelID: "mock" },
|
||||
parts: [{ type: "text", text: "before restart" }],
|
||||
},
|
||||
});
|
||||
expect(firstPrompt.error).toBeUndefined();
|
||||
|
||||
await waitForAssistantMessage(sessionId);
|
||||
await handle.dispose();
|
||||
|
||||
handle = await spawnSandboxAgent({
|
||||
opencodeCompat: true,
|
||||
env: { OPENCODE_COMPAT_DB_PATH: sqlitePath },
|
||||
});
|
||||
client = createOpencodeClient({
|
||||
baseUrl: `${handle.baseUrl}/opencode`,
|
||||
headers: { Authorization: `Bearer ${handle.token}` },
|
||||
});
|
||||
|
||||
const restored = await client.session.get({ path: { id: sessionId } });
|
||||
expect(restored.error).toBeUndefined();
|
||||
expect(restored.data?.id).toBe(sessionId);
|
||||
expect(restored.data?.title).toBe("Persisted Session");
|
||||
|
||||
const secondPrompt = await client.session.prompt({
|
||||
path: { id: sessionId },
|
||||
body: {
|
||||
model: { providerID: "mock", modelID: "mock" },
|
||||
parts: [{ type: "text", text: "after restart" }],
|
||||
},
|
||||
});
|
||||
expect(secondPrompt.error).toBeUndefined();
|
||||
|
||||
const messages = await listMessagesViaHttp(sessionId);
|
||||
expect(messages.length).toBeGreaterThan(2);
|
||||
} finally {
|
||||
rmSync(tempStateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("session.update", () => {
|
||||
|
|
@ -376,6 +482,38 @@ describe("OpenCode-compatible Session API", () => {
|
|||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("should reject prompt model changes after the first prompt", async () => {
|
||||
const created = await client.session.create({ body: { title: "Model Lock" } });
|
||||
const sessionId = created.data?.id!;
|
||||
|
||||
const firstPrompt = await client.session.prompt({
|
||||
path: { id: sessionId },
|
||||
body: {
|
||||
model: { providerID: "mock", modelID: "mock" },
|
||||
parts: [{ type: "text", text: "first" }],
|
||||
},
|
||||
});
|
||||
expect(firstPrompt.error).toBeUndefined();
|
||||
|
||||
const response = await fetch(`${handle.baseUrl}/opencode/session/${sessionId}/message`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${handle.token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: { providerID: "codex", modelID: "gpt-5" },
|
||||
parts: [{ type: "text", text: "second" }],
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data?.errors?.[0]?.message).toBe(
|
||||
"OpenCode compatibility currently does not support changing the model after creating a session. Export with /export and load in to a new session."
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("session.delete", () => {
|
||||
|
|
|
|||
|
|
@ -1,75 +1,31 @@
|
|||
use std::collections::BTreeSet;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use axum::body::Body;
|
||||
use axum::http::{Method, Request, StatusCode};
|
||||
use http_body_util::BodyExt;
|
||||
use sandbox_agent::router::{build_router, AppState, AuthConfig};
|
||||
use sandbox_agent_agent_management::agents::AgentManager;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
use sandbox_agent::opencode_compat::OpenCodeApiDoc;
|
||||
use serde_json::Value;
|
||||
use utoipa::OpenApi;
|
||||
#[tokio::test]
|
||||
async fn opencode_routes_are_mounted() {
|
||||
let install_dir = tempfile::tempdir().expect("tempdir");
|
||||
let manager = AgentManager::new(install_dir.path()).expect("agent manager");
|
||||
let app = build_router(AppState::new(AuthConfig::disabled(), manager));
|
||||
|
||||
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();
|
||||
if !official_path.exists() {
|
||||
eprintln!(
|
||||
"skipping OpenCode OpenAPI parity check; official spec missing at {:?}",
|
||||
official_path
|
||||
);
|
||||
return;
|
||||
}
|
||||
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}");
|
||||
}
|
||||
let request = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/opencode/session")
|
||||
.body(Body::empty())
|
||||
.expect("build request");
|
||||
|
||||
let response = app.oneshot(request).await.expect("response");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("collect body")
|
||||
.to_bytes();
|
||||
let parsed: serde_json::Value = serde_json::from_slice(&body).expect("json body");
|
||||
assert!(parsed.is_array());
|
||||
}
|
||||
|
|
|
|||
237
server/packages/sandbox-agent/tests/v1_agent_process_matrix.rs
Normal file
237
server/packages/sandbox-agent/tests/v1_agent_process_matrix.rs
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Method, Request, StatusCode};
|
||||
use futures::StreamExt;
|
||||
use http_body_util::BodyExt;
|
||||
use sandbox_agent::router::{build_router, AppState, AuthConfig};
|
||||
use sandbox_agent_agent_management::agents::AgentManager;
|
||||
use serde_json::{json, Value};
|
||||
use tempfile::TempDir;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
struct TestApp {
|
||||
app: axum::Router,
|
||||
_install_dir: TempDir,
|
||||
}
|
||||
|
||||
impl TestApp {
|
||||
fn with_setup<F>(setup: F) -> Self
|
||||
where
|
||||
F: FnOnce(&Path),
|
||||
{
|
||||
let install_dir = tempfile::tempdir().expect("create temp install dir");
|
||||
setup(install_dir.path());
|
||||
let manager = AgentManager::new(install_dir.path()).expect("create agent manager");
|
||||
let state = AppState::new(AuthConfig::disabled(), manager);
|
||||
let app = build_router(state);
|
||||
Self {
|
||||
app,
|
||||
_install_dir: install_dir,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_executable(path: &Path, script: &str) {
|
||||
fs::write(path, script).expect("write executable");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = fs::metadata(path).expect("metadata").permissions();
|
||||
perms.set_mode(0o755);
|
||||
fs::set_permissions(path, perms).expect("set mode");
|
||||
}
|
||||
}
|
||||
|
||||
fn write_stub_native(path: &Path, agent: &str) {
|
||||
let script = format!("#!/usr/bin/env sh\necho \"{agent} 0.0.1\"\nexit 0\n");
|
||||
write_executable(path, &script);
|
||||
}
|
||||
|
||||
fn write_stub_agent_process(path: &Path, agent: &str) {
|
||||
let script = format!(
|
||||
r#"#!/usr/bin/env sh
|
||||
if [ "${{1:-}}" = "--help" ] || [ "${{1:-}}" = "--version" ] || [ "${{1:-}}" = "version" ] || [ "${{1:-}}" = "-V" ]; then
|
||||
echo "{agent}-agent-process 0.0.1"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
while IFS= read -r line; do
|
||||
method=$(printf '%s\n' "$line" | sed -n 's/.*"method"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
|
||||
id=$(printf '%s\n' "$line" | sed -n 's/.*"id"[[:space:]]*:[[:space:]]*\([^,}}]*\).*/\1/p')
|
||||
|
||||
if [ -n "$method" ]; then
|
||||
printf '{{"jsonrpc":"2.0","method":"server/echo","params":{{"agent":"{agent}","method":"%s"}}}}\n' "$method"
|
||||
fi
|
||||
|
||||
if [ -n "$method" ] && [ -n "$id" ]; then
|
||||
printf '{{"jsonrpc":"2.0","id":%s,"result":{{"ok":true,"agent":"{agent}","echoedMethod":"%s"}}}}\n' "$id" "$method"
|
||||
fi
|
||||
done
|
||||
"#
|
||||
);
|
||||
|
||||
write_executable(path, &script);
|
||||
}
|
||||
|
||||
fn setup_stub_artifacts(install_dir: &Path, agent: &str) {
|
||||
let native = install_dir.join(agent);
|
||||
write_stub_native(&native, agent);
|
||||
|
||||
let agent_processes = install_dir.join("agent_processes");
|
||||
fs::create_dir_all(&agent_processes).expect("create agent processes dir");
|
||||
let launcher = if cfg!(windows) {
|
||||
agent_processes.join(format!("{agent}-acp.cmd"))
|
||||
} else {
|
||||
agent_processes.join(format!("{agent}-acp"))
|
||||
};
|
||||
write_stub_agent_process(&launcher, agent);
|
||||
}
|
||||
|
||||
fn setup_stub_agent_process_only(install_dir: &Path, agent: &str) {
|
||||
let agent_processes = install_dir.join("agent_processes");
|
||||
fs::create_dir_all(&agent_processes).expect("create agent processes dir");
|
||||
let launcher = if cfg!(windows) {
|
||||
agent_processes.join(format!("{agent}-acp.cmd"))
|
||||
} else {
|
||||
agent_processes.join(format!("{agent}-acp"))
|
||||
};
|
||||
write_stub_agent_process(&launcher, agent);
|
||||
}
|
||||
|
||||
async fn send_request(
|
||||
app: &axum::Router,
|
||||
method: Method,
|
||||
uri: &str,
|
||||
body: Option<Value>,
|
||||
) -> (StatusCode, Vec<u8>) {
|
||||
let mut builder = Request::builder().method(method).uri(uri);
|
||||
let request_body = if let Some(body) = body {
|
||||
builder = builder.header("content-type", "application/json");
|
||||
Body::from(body.to_string())
|
||||
} else {
|
||||
Body::empty()
|
||||
};
|
||||
|
||||
let request = builder.body(request_body).expect("build request");
|
||||
let response = app.clone().oneshot(request).await.expect("request handled");
|
||||
let status = response.status();
|
||||
let bytes = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("collect body")
|
||||
.to_bytes();
|
||||
|
||||
(status, bytes.to_vec())
|
||||
}
|
||||
|
||||
fn parse_json(bytes: &[u8]) -> Value {
|
||||
if bytes.is_empty() {
|
||||
Value::Null
|
||||
} else {
|
||||
serde_json::from_slice(bytes).expect("valid json")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn agent_process_matrix_smoke_and_jsonrpc_conformance() {
|
||||
let native_agents = ["claude", "codex", "opencode"];
|
||||
let agent_process_only_agents = ["pi", "cursor"];
|
||||
let agents: Vec<&str> = native_agents
|
||||
.iter()
|
||||
.chain(agent_process_only_agents.iter())
|
||||
.copied()
|
||||
.collect();
|
||||
let test_app = TestApp::with_setup(|install_dir| {
|
||||
for agent in native_agents {
|
||||
setup_stub_artifacts(install_dir, agent);
|
||||
}
|
||||
for agent in agent_process_only_agents {
|
||||
setup_stub_agent_process_only(install_dir, agent);
|
||||
}
|
||||
});
|
||||
|
||||
for agent in agents {
|
||||
let initialize = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "1.0",
|
||||
"clientCapabilities": {}
|
||||
}
|
||||
});
|
||||
let (status, init_body) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
&format!("/v1/acp/{agent}-server?agent={agent}"),
|
||||
Some(initialize),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK, "{agent}: initialize status");
|
||||
let init_json = parse_json(&init_body);
|
||||
assert_eq!(init_json["jsonrpc"], "2.0", "{agent}: initialize jsonrpc");
|
||||
assert_eq!(init_json["id"], 1, "{agent}: initialize id");
|
||||
assert_eq!(
|
||||
init_json["result"]["agent"], agent,
|
||||
"{agent}: initialize agent"
|
||||
);
|
||||
|
||||
let new_session = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "session/new",
|
||||
"params": {
|
||||
"cwd": "/tmp"
|
||||
}
|
||||
});
|
||||
let (status, new_body) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
&format!("/v1/acp/{agent}-server"),
|
||||
Some(new_session),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK, "{agent}: session/new status");
|
||||
let new_json = parse_json(&new_body);
|
||||
assert_eq!(new_json["jsonrpc"], "2.0", "{agent}: session/new jsonrpc");
|
||||
assert_eq!(new_json["id"], 2, "{agent}: session/new id");
|
||||
assert_eq!(new_json["result"]["echoedMethod"], "session/new");
|
||||
|
||||
let request = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(format!("/v1/acp/{agent}-server"))
|
||||
.body(Body::empty())
|
||||
.expect("build sse request");
|
||||
|
||||
let response = test_app
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(request)
|
||||
.await
|
||||
.expect("sse response");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let mut stream = response.into_body().into_data_stream();
|
||||
let chunk = tokio::time::timeout(std::time::Duration::from_secs(5), async move {
|
||||
while let Some(item) = stream.next().await {
|
||||
let bytes = item.expect("sse chunk");
|
||||
let text = String::from_utf8_lossy(&bytes).to_string();
|
||||
if text.contains("server/echo") {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
panic!("sse ended")
|
||||
})
|
||||
.await
|
||||
.expect("sse timeout");
|
||||
|
||||
assert!(
|
||||
chunk.contains("server/echo"),
|
||||
"{agent}: missing server/echo"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -188,45 +188,35 @@ fn parse_json(bytes: &[u8]) -> Value {
|
|||
}
|
||||
}
|
||||
|
||||
fn initialize_payload(agent: &str) -> Value {
|
||||
fn initialize_payload() -> Value {
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "1.0",
|
||||
"clientCapabilities": {},
|
||||
"_meta": {
|
||||
"sandboxagent.dev": {
|
||||
"agent": agent,
|
||||
}
|
||||
}
|
||||
"clientCapabilities": {}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn create_connection(app: &Router, agent: &str, headers: &[(&str, &str)]) -> String {
|
||||
let initialize = initialize_payload(agent);
|
||||
let (status, response_headers, _bytes) =
|
||||
send_request(app, Method::POST, "/v2/rpc", Some(initialize), headers).await;
|
||||
async fn bootstrap_server(app: &Router, server_id: &str, agent: &str) {
|
||||
let initialize = initialize_payload();
|
||||
let (status, _, _body) = send_request(
|
||||
app,
|
||||
Method::POST,
|
||||
&format!("/v1/acp/{server_id}?agent={agent}"),
|
||||
Some(initialize),
|
||||
&[],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
|
||||
response_headers
|
||||
.get("x-acp-connection-id")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(|value| value.to_string())
|
||||
.expect("connection id")
|
||||
}
|
||||
|
||||
async fn create_mock_connection(app: &Router, headers: &[(&str, &str)]) -> String {
|
||||
create_connection(app, "mock", headers).await
|
||||
}
|
||||
|
||||
async fn read_first_sse_data(app: &Router, connection_id: &str) -> String {
|
||||
async fn read_first_sse_data(app: &Router, server_id: &str) -> String {
|
||||
let request = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/v2/rpc")
|
||||
.header("x-acp-connection-id", connection_id)
|
||||
.uri(format!("/v1/acp/{server_id}"))
|
||||
.body(Body::empty())
|
||||
.expect("build request");
|
||||
|
||||
|
|
@ -250,13 +240,12 @@ async fn read_first_sse_data(app: &Router, connection_id: &str) -> String {
|
|||
|
||||
async fn read_first_sse_data_with_last_id(
|
||||
app: &Router,
|
||||
connection_id: &str,
|
||||
server_id: &str,
|
||||
last_event_id: u64,
|
||||
) -> String {
|
||||
let request = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/v2/rpc")
|
||||
.header("x-acp-connection-id", connection_id)
|
||||
.uri(format!("/v1/acp/{server_id}"))
|
||||
.header("last-event-id", last_event_id.to_string())
|
||||
.body(Body::empty())
|
||||
.expect("build request");
|
||||
|
|
@ -279,38 +268,6 @@ async fn read_first_sse_data_with_last_id(
|
|||
.expect("timed out reading sse")
|
||||
}
|
||||
|
||||
async fn sse_has_data_with_last_id(
|
||||
app: &Router,
|
||||
connection_id: &str,
|
||||
last_event_id: u64,
|
||||
timeout: Duration,
|
||||
) -> bool {
|
||||
let request = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/v2/rpc")
|
||||
.header("x-acp-connection-id", connection_id)
|
||||
.header("last-event-id", last_event_id.to_string())
|
||||
.body(Body::empty())
|
||||
.expect("build request");
|
||||
|
||||
let response = app.clone().oneshot(request).await.expect("sse response");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let mut stream = response.into_body().into_data_stream();
|
||||
tokio::time::timeout(timeout, async move {
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let bytes = chunk.expect("stream chunk");
|
||||
let text = String::from_utf8_lossy(&bytes).to_string();
|
||||
if text.contains("data:") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
})
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn parse_sse_data(chunk: &str) -> Value {
|
||||
let data = chunk
|
||||
.lines()
|
||||
|
|
@ -328,27 +285,9 @@ fn parse_sse_event_id(chunk: &str) -> u64 {
|
|||
.expect("sse event id")
|
||||
}
|
||||
|
||||
async fn read_sse_until_contains(
|
||||
app: &Router,
|
||||
connection_id: &str,
|
||||
mut last_event_id: u64,
|
||||
needle: &str,
|
||||
max_steps: usize,
|
||||
) -> Option<String> {
|
||||
for _ in 0..max_steps {
|
||||
let chunk = read_first_sse_data_with_last_id(app, connection_id, last_event_id).await;
|
||||
let event_id = parse_sse_event_id(&chunk);
|
||||
last_event_id = event_id;
|
||||
if chunk.contains(needle) {
|
||||
return Some(chunk);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[path = "v2_api/acp_extensions.rs"]
|
||||
mod acp_extensions;
|
||||
#[path = "v2_api/acp_transport.rs"]
|
||||
#[path = "v1_api/acp_transport.rs"]
|
||||
mod acp_transport;
|
||||
#[path = "v2_api/control_plane.rs"]
|
||||
#[path = "v1_api/config_endpoints.rs"]
|
||||
mod config_endpoints;
|
||||
#[path = "v1_api/control_plane.rs"]
|
||||
mod control_plane;
|
||||
307
server/packages/sandbox-agent/tests/v1_api/acp_transport.rs
Normal file
307
server/packages/sandbox-agent/tests/v1_api/acp_transport.rs
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
use super::*;
|
||||
|
||||
fn write_stub_native(path: &Path, agent: &str) {
|
||||
let script = format!("#!/usr/bin/env sh\necho \"{agent} 0.0.1\"\nexit 0\n");
|
||||
write_executable(path, &script);
|
||||
}
|
||||
|
||||
fn write_stub_agent_process(path: &Path, agent: &str) {
|
||||
let script = format!(
|
||||
r#"#!/usr/bin/env sh
|
||||
if [ "${{1:-}}" = "--help" ] || [ "${{1:-}}" = "--version" ] || [ "${{1:-}}" = "version" ] || [ "${{1:-}}" = "-V" ]; then
|
||||
echo "{agent}-agent-process 0.0.1"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
while IFS= read -r line; do
|
||||
method=$(printf '%s\n' "$line" | sed -n 's/.*"method"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
|
||||
id=$(printf '%s\n' "$line" | sed -n 's/.*"id"[[:space:]]*:[[:space:]]*\([^,}}]*\).*/\1/p')
|
||||
|
||||
if [ -n "$method" ]; then
|
||||
printf '{{"jsonrpc":"2.0","method":"server/echo","params":{{"method":"%s"}}}}\n' "$method"
|
||||
fi
|
||||
|
||||
if [ -n "$method" ] && [ -n "$id" ]; then
|
||||
printf '{{"jsonrpc":"2.0","id":%s,"result":{{"ok":true,"echoedMethod":"%s"}}}}\n' "$id" "$method"
|
||||
elif [ -z "$method" ] && [ -n "$id" ]; then
|
||||
printf '{{"jsonrpc":"2.0","method":"server/client_response","params":{{"id":%s}}}}\n' "$id"
|
||||
fi
|
||||
done
|
||||
"#
|
||||
);
|
||||
|
||||
write_executable(path, &script);
|
||||
}
|
||||
|
||||
fn setup_stub_artifacts(install_dir: &Path, agent: &str) {
|
||||
let native = install_dir.join(agent);
|
||||
write_stub_native(&native, agent);
|
||||
|
||||
let agent_processes = install_dir.join("agent_processes");
|
||||
fs::create_dir_all(&agent_processes).expect("create agent processes dir");
|
||||
let launcher = if cfg!(windows) {
|
||||
agent_processes.join(format!("{agent}-acp.cmd"))
|
||||
} else {
|
||||
agent_processes.join(format!("{agent}-acp"))
|
||||
};
|
||||
write_stub_agent_process(&launcher, agent);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn acp_bootstrap_requires_agent_query() {
|
||||
let test_app = TestApp::new(AuthConfig::disabled());
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v1/acp/server-a",
|
||||
Some(initialize_payload()),
|
||||
&[],
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(parse_json(&body)["status"], 400);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn acp_round_trip_and_replay() {
|
||||
let test_app = TestApp::with_setup(AuthConfig::disabled(), |install_dir| {
|
||||
setup_stub_artifacts(install_dir, "codex");
|
||||
});
|
||||
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v1/acp/server-replay?agent=codex",
|
||||
Some(initialize_payload()),
|
||||
&[],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(parse_json(&body)["result"]["echoedMethod"], "initialize");
|
||||
|
||||
let prompt = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "session/prompt",
|
||||
"params": {
|
||||
"sessionId": "s-1",
|
||||
"prompt": [{"type": "text", "text": "hello"}]
|
||||
}
|
||||
});
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v1/acp/server-replay",
|
||||
Some(prompt),
|
||||
&[],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(
|
||||
parse_json(&body)["result"]["echoedMethod"],
|
||||
"session/prompt"
|
||||
);
|
||||
|
||||
let first_chunk = read_first_sse_data_with_last_id(&test_app.app, "server-replay", 0).await;
|
||||
let first_event_id = parse_sse_event_id(&first_chunk);
|
||||
let first_event = parse_sse_data(&first_chunk);
|
||||
assert_eq!(first_event["method"], "server/echo");
|
||||
|
||||
let second_chunk =
|
||||
read_first_sse_data_with_last_id(&test_app.app, "server-replay", first_event_id).await;
|
||||
let second_event_id = parse_sse_event_id(&second_chunk);
|
||||
assert!(second_event_id > first_event_id);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn acp_agent_mismatch_returns_conflict() {
|
||||
let test_app = TestApp::with_setup(AuthConfig::disabled(), |install_dir| {
|
||||
setup_stub_artifacts(install_dir, "codex");
|
||||
setup_stub_artifacts(install_dir, "claude");
|
||||
});
|
||||
|
||||
bootstrap_server(&test_app.app, "server-mismatch", "codex").await;
|
||||
|
||||
let request = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "session/new",
|
||||
"params": {}
|
||||
});
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v1/acp/server-mismatch?agent=claude",
|
||||
Some(request),
|
||||
&[],
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(status, StatusCode::CONFLICT);
|
||||
assert_eq!(parse_json(&body)["status"], 409);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn acp_get_unknown_returns_not_found() {
|
||||
let test_app = TestApp::new(AuthConfig::disabled());
|
||||
|
||||
let (status, _, body) =
|
||||
send_request(&test_app.app, Method::GET, "/v1/acp/missing", None, &[]).await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
assert_eq!(parse_json(&body)["status"], 404);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn acp_delete_is_idempotent() {
|
||||
let test_app = TestApp::new(AuthConfig::disabled());
|
||||
|
||||
let (status, _, _) = send_request(
|
||||
&test_app.app,
|
||||
Method::DELETE,
|
||||
"/v1/acp/server-delete",
|
||||
None,
|
||||
&[],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NO_CONTENT);
|
||||
|
||||
let (status, _, _) = send_request(
|
||||
&test_app.app,
|
||||
Method::DELETE,
|
||||
"/v1/acp/server-delete",
|
||||
None,
|
||||
&[],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NO_CONTENT);
|
||||
|
||||
let request = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "1.0",
|
||||
"clientCapabilities": {}
|
||||
}
|
||||
});
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v1/acp/server-delete",
|
||||
Some(request),
|
||||
&[],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(parse_json(&body)["status"], 400);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn acp_list_servers_returns_active_instances() {
|
||||
let test_app = TestApp::with_setup(AuthConfig::disabled(), |install_dir| {
|
||||
setup_stub_artifacts(install_dir, "codex");
|
||||
});
|
||||
|
||||
bootstrap_server(&test_app.app, "server-1", "codex").await;
|
||||
bootstrap_server(&test_app.app, "server-2", "codex").await;
|
||||
|
||||
let (status, _, body) = send_request(&test_app.app, Method::GET, "/v1/acp", None, &[]).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
|
||||
let parsed = parse_json(&body);
|
||||
let servers = parsed["servers"].as_array().expect("servers array");
|
||||
assert!(servers
|
||||
.iter()
|
||||
.any(|server| server["serverId"] == "server-1"));
|
||||
assert!(servers
|
||||
.iter()
|
||||
.any(|server| server["serverId"] == "server-2"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn sandboxagent_methods_are_not_handled_specially() {
|
||||
let test_app = TestApp::with_setup(AuthConfig::disabled(), |install_dir| {
|
||||
setup_stub_artifacts(install_dir, "codex");
|
||||
});
|
||||
|
||||
bootstrap_server(&test_app.app, "server-ext", "codex").await;
|
||||
|
||||
let request = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 22,
|
||||
"method": "_sandboxagent/session/list",
|
||||
"params": {}
|
||||
});
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v1/acp/server-ext",
|
||||
Some(request),
|
||||
&[],
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(
|
||||
parse_json(&body)["result"]["echoedMethod"],
|
||||
"_sandboxagent/session/list"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_requires_json_content_type() {
|
||||
let test_app = TestApp::new(AuthConfig::disabled());
|
||||
let payload = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"1.0","clientCapabilities":{}}}"#
|
||||
.to_vec();
|
||||
let (status, _, body) = send_request_raw(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v1/acp/server-content?agent=mock",
|
||||
Some(payload),
|
||||
&[],
|
||||
Some("text/plain"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::UNSUPPORTED_MEDIA_TYPE);
|
||||
assert_eq!(parse_json(&body)["status"], 415);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sse_rejects_non_sse_accept() {
|
||||
let test_app = TestApp::new(AuthConfig::disabled());
|
||||
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::GET,
|
||||
"/v1/acp/server-a",
|
||||
None,
|
||||
&[("accept", "application/json")],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NOT_ACCEPTABLE);
|
||||
assert_eq!(parse_json(&body)["status"], 406);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_last_event_id_returns_bad_request() {
|
||||
let test_app = TestApp::new(AuthConfig::disabled());
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::GET,
|
||||
"/v1/acp/server-a",
|
||||
None,
|
||||
&[("last-event-id", "not-a-number")],
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(
|
||||
parse_json(&body)["detail"],
|
||||
"invalid request: Last-Event-ID must be a positive integer"
|
||||
);
|
||||
}
|
||||
152
server/packages/sandbox-agent/tests/v1_api/config_endpoints.rs
Normal file
152
server/packages/sandbox-agent/tests/v1_api/config_endpoints.rs
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn mcp_config_requires_directory_and_name() {
|
||||
let test_app = TestApp::new(AuthConfig::disabled());
|
||||
|
||||
let (status, _, _) =
|
||||
send_request(&test_app.app, Method::GET, "/v1/config/mcp", None, &[]).await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
|
||||
let (status, _, _) = send_request(
|
||||
&test_app.app,
|
||||
Method::PUT,
|
||||
"/v1/config/mcp?directory=/tmp",
|
||||
Some(json!({"type": "local", "command": "mcp"})),
|
||||
&[],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mcp_config_crud_round_trip() {
|
||||
let test_app = TestApp::new(AuthConfig::disabled());
|
||||
let project = tempfile::tempdir().expect("tempdir");
|
||||
let directory = project.path().to_string_lossy().to_string();
|
||||
|
||||
let entry = json!({
|
||||
"type": "local",
|
||||
"command": "node",
|
||||
"args": ["server.js"],
|
||||
"env": {"LOG_LEVEL": "debug"},
|
||||
"timeoutMs": 2000,
|
||||
"cwd": "/workspace"
|
||||
});
|
||||
|
||||
let (status, _, _) = send_request(
|
||||
&test_app.app,
|
||||
Method::PUT,
|
||||
&format!("/v1/config/mcp?directory={directory}&mcpName=filesystem"),
|
||||
Some(entry.clone()),
|
||||
&[],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NO_CONTENT);
|
||||
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::GET,
|
||||
&format!("/v1/config/mcp?directory={directory}&mcpName=filesystem"),
|
||||
None,
|
||||
&[],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(parse_json(&body), entry);
|
||||
|
||||
let (status, _, _) = send_request(
|
||||
&test_app.app,
|
||||
Method::DELETE,
|
||||
&format!("/v1/config/mcp?directory={directory}&mcpName=filesystem"),
|
||||
None,
|
||||
&[],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NO_CONTENT);
|
||||
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::GET,
|
||||
&format!("/v1/config/mcp?directory={directory}&mcpName=filesystem"),
|
||||
None,
|
||||
&[],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
assert_eq!(parse_json(&body)["status"], 404);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skills_config_requires_directory_and_name() {
|
||||
let test_app = TestApp::new(AuthConfig::disabled());
|
||||
|
||||
let (status, _, _) =
|
||||
send_request(&test_app.app, Method::GET, "/v1/config/skills", None, &[]).await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
|
||||
let (status, _, _) = send_request(
|
||||
&test_app.app,
|
||||
Method::PUT,
|
||||
"/v1/config/skills?directory=/tmp",
|
||||
Some(json!({"sources": []})),
|
||||
&[],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skills_config_crud_round_trip() {
|
||||
let test_app = TestApp::new(AuthConfig::disabled());
|
||||
let project = tempfile::tempdir().expect("tempdir");
|
||||
let directory = project.path().to_string_lossy().to_string();
|
||||
|
||||
let entry = json!({
|
||||
"sources": [
|
||||
{"type": "github", "source": "rivet-dev/skills", "skills": ["sandbox-agent"], "ref": "main"},
|
||||
{"type": "local", "source": "/workspace/my-skill"}
|
||||
]
|
||||
});
|
||||
|
||||
let (status, _, _) = send_request(
|
||||
&test_app.app,
|
||||
Method::PUT,
|
||||
&format!("/v1/config/skills?directory={directory}&skillName=default"),
|
||||
Some(entry.clone()),
|
||||
&[],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NO_CONTENT);
|
||||
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::GET,
|
||||
&format!("/v1/config/skills?directory={directory}&skillName=default"),
|
||||
None,
|
||||
&[],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(parse_json(&body), entry);
|
||||
|
||||
let (status, _, _) = send_request(
|
||||
&test_app.app,
|
||||
Method::DELETE,
|
||||
&format!("/v1/config/skills?directory={directory}&skillName=default"),
|
||||
None,
|
||||
&[],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NO_CONTENT);
|
||||
|
||||
let (status, _, _) = send_request(
|
||||
&test_app.app,
|
||||
Method::GET,
|
||||
&format!("/v1/config/skills?directory={directory}&skillName=default"),
|
||||
None,
|
||||
&[],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
}
|
||||
218
server/packages/sandbox-agent/tests/v1_api/control_plane.rs
Normal file
218
server/packages/sandbox-agent/tests/v1_api/control_plane.rs
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn v1_health_removed_legacy_and_opencode_unmounted() {
|
||||
let test_app = TestApp::new(AuthConfig::disabled());
|
||||
|
||||
let (status, _, body) = send_request(&test_app.app, Method::GET, "/v1/health", None, &[]).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(parse_json(&body)["status"], "ok");
|
||||
|
||||
let (status, _, _body) =
|
||||
send_request(&test_app.app, Method::GET, "/v1/anything", None, &[]).await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
|
||||
let (status, _, _) =
|
||||
send_request(&test_app.app, Method::GET, "/opencode/session", None, &[]).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn v1_auth_enforced_when_token_configured() {
|
||||
let test_app = TestApp::new(AuthConfig::with_token("secret-token".to_string()));
|
||||
|
||||
let (status, _, _) = send_request(&test_app.app, Method::GET, "/v1/health", None, &[]).await;
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::GET,
|
||||
"/v1/health",
|
||||
None,
|
||||
&[("authorization", "Bearer secret-token")],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(parse_json(&body)["status"], "ok");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn v1_filesystem_endpoints_round_trip() {
|
||||
let test_app = TestApp::new(AuthConfig::disabled());
|
||||
|
||||
let (status, _, body) = send_request_raw(
|
||||
&test_app.app,
|
||||
Method::PUT,
|
||||
"/v1/fs/file?path=docs/file.txt",
|
||||
Some(b"hello".to_vec()),
|
||||
&[],
|
||||
Some("application/octet-stream"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(parse_json(&body)["bytesWritten"], 5);
|
||||
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::GET,
|
||||
"/v1/fs/stat?path=docs/file.txt",
|
||||
None,
|
||||
&[],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(parse_json(&body)["entryType"], "file");
|
||||
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::GET,
|
||||
"/v1/fs/entries?path=docs",
|
||||
None,
|
||||
&[],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let entries = parse_json(&body).as_array().cloned().expect("array");
|
||||
assert!(entries.iter().any(|entry| entry["name"] == "file.txt"));
|
||||
|
||||
let (status, headers, body) = send_request_raw(
|
||||
&test_app.app,
|
||||
Method::GET,
|
||||
"/v1/fs/file?path=docs/file.txt",
|
||||
None,
|
||||
&[],
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(
|
||||
headers
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or(""),
|
||||
"application/octet-stream"
|
||||
);
|
||||
assert_eq!(String::from_utf8_lossy(&body), "hello");
|
||||
|
||||
let move_body = json!({
|
||||
"from": "docs/file.txt",
|
||||
"to": "docs/renamed.txt",
|
||||
"overwrite": true
|
||||
});
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v1/fs/move",
|
||||
Some(move_body),
|
||||
&[],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(parse_json(&body)["to"]
|
||||
.as_str()
|
||||
.expect("to path")
|
||||
.ends_with("docs/renamed.txt"));
|
||||
|
||||
let (status, _, _) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v1/fs/mkdir?path=docs/nested",
|
||||
None,
|
||||
&[],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
|
||||
let (status, _, _) = send_request(
|
||||
&test_app.app,
|
||||
Method::DELETE,
|
||||
"/v1/fs/entry?path=docs/nested&recursive=true",
|
||||
None,
|
||||
&[],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn require_preinstall_blocks_missing_agent() {
|
||||
let test_app = {
|
||||
let _preinstall = EnvVarGuard::set("SANDBOX_AGENT_REQUIRE_PREINSTALL", "true");
|
||||
TestApp::new(AuthConfig::disabled())
|
||||
};
|
||||
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v1/acp/server-a?agent=codex",
|
||||
Some(initialize_payload()),
|
||||
&[],
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
let parsed = parse_json(&body);
|
||||
assert_eq!(parsed["status"], 404);
|
||||
assert_eq!(parsed["title"], "Agent Not Installed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn lazy_install_runs_on_first_bootstrap() {
|
||||
let registry_url = serve_registry_once(json!({
|
||||
"agents": [
|
||||
{
|
||||
"id": "codex-acp",
|
||||
"version": "1.2.3",
|
||||
"distribution": {
|
||||
"npx": {
|
||||
"package": "@example/codex-acp@1.2.3",
|
||||
"args": [],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}));
|
||||
|
||||
let _registry = EnvVarGuard::set("SANDBOX_AGENT_ACP_REGISTRY_URL", ®istry_url);
|
||||
let test_app = TestApp::with_setup(AuthConfig::disabled(), |install_path| {
|
||||
fs::create_dir_all(install_path.join("agent_processes"))
|
||||
.expect("create agent processes dir");
|
||||
write_executable(&install_path.join("codex"), "#!/usr/bin/env sh\nexit 0\n");
|
||||
fs::create_dir_all(install_path.join("bin")).expect("create bin dir");
|
||||
write_executable(
|
||||
&install_path.join("bin").join("npx"),
|
||||
"#!/usr/bin/env sh\nwhile IFS= read -r _line; do :; done\n",
|
||||
);
|
||||
});
|
||||
|
||||
let original_path = std::env::var_os("PATH").unwrap_or_default();
|
||||
let mut paths = vec![test_app.install_path().join("bin")];
|
||||
paths.extend(std::env::split_paths(&original_path));
|
||||
let merged_path = std::env::join_paths(paths).expect("join PATH");
|
||||
let _path_guard = EnvVarGuard::set_os("PATH", merged_path.as_os_str());
|
||||
|
||||
let (status, _, _) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v1/acp/server-lazy?agent=codex",
|
||||
Some(json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "1.0",
|
||||
"clientCapabilities": {}
|
||||
}
|
||||
})),
|
||||
&[],
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(status, StatusCode::ACCEPTED);
|
||||
assert!(test_app
|
||||
.install_path()
|
||||
.join("agent_processes/codex-acp")
|
||||
.exists());
|
||||
}
|
||||
|
|
@ -1,628 +0,0 @@
|
|||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{header, HeaderMap, Method, Request, StatusCode};
|
||||
use axum::Router;
|
||||
use futures::StreamExt;
|
||||
use http_body_util::BodyExt;
|
||||
use sandbox_agent::router::{build_router, AppState, AuthConfig};
|
||||
use sandbox_agent_agent_management::agents::AgentManager;
|
||||
use serde_json::{json, Value};
|
||||
use tempfile::TempDir;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
struct TestApp {
|
||||
app: Router,
|
||||
_install_dir: TempDir,
|
||||
}
|
||||
|
||||
impl TestApp {
|
||||
fn with_setup<F>(setup: F) -> Self
|
||||
where
|
||||
F: FnOnce(&Path),
|
||||
{
|
||||
let install_dir = tempfile::tempdir().expect("create temp install dir");
|
||||
setup(install_dir.path());
|
||||
let manager = AgentManager::new(install_dir.path()).expect("create agent manager");
|
||||
let state = AppState::new(AuthConfig::disabled(), manager);
|
||||
let app = build_router(state);
|
||||
Self {
|
||||
app,
|
||||
_install_dir: install_dir,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_request(
|
||||
app: &Router,
|
||||
method: Method,
|
||||
uri: &str,
|
||||
body: Option<Value>,
|
||||
headers: &[(&str, &str)],
|
||||
) -> (StatusCode, HeaderMap, Vec<u8>) {
|
||||
let mut builder = Request::builder().method(method).uri(uri);
|
||||
for (name, value) in headers {
|
||||
builder = builder.header(*name, *value);
|
||||
}
|
||||
|
||||
let request_body = if let Some(body) = body {
|
||||
builder = builder.header(header::CONTENT_TYPE, "application/json");
|
||||
Body::from(body.to_string())
|
||||
} else {
|
||||
Body::empty()
|
||||
};
|
||||
|
||||
let request = builder.body(request_body).expect("build request");
|
||||
let response = app.clone().oneshot(request).await.expect("request handled");
|
||||
let status = response.status();
|
||||
let headers = response.headers().clone();
|
||||
let bytes = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("collect body")
|
||||
.to_bytes();
|
||||
(status, headers, bytes.to_vec())
|
||||
}
|
||||
|
||||
fn parse_json(bytes: &[u8]) -> Value {
|
||||
if bytes.is_empty() {
|
||||
Value::Null
|
||||
} else {
|
||||
serde_json::from_slice(bytes).expect("valid json")
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_first_sse_chunk(app: &Router, connection_id: &str) -> String {
|
||||
let request = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/v2/rpc")
|
||||
.header("x-acp-connection-id", connection_id)
|
||||
.body(Body::empty())
|
||||
.expect("build request");
|
||||
|
||||
let response = app.clone().oneshot(request).await.expect("sse response");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let mut stream = response.into_body().into_data_stream();
|
||||
tokio::time::timeout(Duration::from_secs(5), async move {
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let bytes = chunk.expect("stream chunk");
|
||||
let text = String::from_utf8_lossy(&bytes).to_string();
|
||||
if text.contains("data:") {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
panic!("SSE stream ended before data chunk")
|
||||
})
|
||||
.await
|
||||
.expect("timed out reading sse")
|
||||
}
|
||||
|
||||
fn parse_sse_data(chunk: &str) -> Value {
|
||||
let data = chunk
|
||||
.lines()
|
||||
.filter_map(|line| line.strip_prefix("data: "))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
serde_json::from_str(&data).expect("valid SSE payload json")
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn set_executable(path: &Path) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = fs::metadata(path).expect("metadata").permissions();
|
||||
perms.set_mode(0o755);
|
||||
fs::set_permissions(path, perms).expect("set executable bit");
|
||||
}
|
||||
|
||||
fn write_stub_native(path: &Path, agent: &str) {
|
||||
let script = format!("#!/usr/bin/env sh\necho \"{agent} 0.0.1\"\nexit 0\n");
|
||||
fs::write(path, script).expect("write native stub");
|
||||
#[cfg(unix)]
|
||||
set_executable(path);
|
||||
}
|
||||
|
||||
fn write_stub_agent_process(path: &Path, agent: &str) {
|
||||
write_stub_agent_process_with_counter(path, agent, None);
|
||||
}
|
||||
|
||||
fn write_stub_agent_process_with_counter(
|
||||
path: &Path,
|
||||
agent: &str,
|
||||
start_counter_file: Option<&Path>,
|
||||
) {
|
||||
let start_counter_block = start_counter_file.map_or_else(String::new, |file| {
|
||||
let path = file.display();
|
||||
format!(
|
||||
r#"
|
||||
count=0
|
||||
if [ -f "{path}" ]; then
|
||||
count=$(cat "{path}")
|
||||
fi
|
||||
count=$((count + 1))
|
||||
printf '%s' "$count" > "{path}"
|
||||
"#
|
||||
)
|
||||
});
|
||||
let script = format!(
|
||||
r#"#!/usr/bin/env sh
|
||||
if [ "${{1:-}}" = "--help" ] || [ "${{1:-}}" = "--version" ] || [ "${{1:-}}" = "version" ] || [ "${{1:-}}" = "-V" ]; then
|
||||
echo "{agent}-agent-process 0.0.1"
|
||||
exit 0
|
||||
fi
|
||||
if [ "${{1:-}}" = "acp" ]; then
|
||||
shift
|
||||
fi
|
||||
{start_counter_block}
|
||||
|
||||
SESSION_ID="{agent}-session-1"
|
||||
while IFS= read -r line; do
|
||||
method=$(printf '%s\n' "$line" | sed -n 's/.*"method"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
|
||||
id=$(printf '%s\n' "$line" | sed -n 's/.*"id"[[:space:]]*:[[:space:]]*\([^,}}]*\).*/\1/p')
|
||||
|
||||
case "$method" in
|
||||
initialize)
|
||||
printf '{{"jsonrpc":"2.0","id":%s,"result":{{"protocolVersion":"1.0","agentCapabilities":{{"canSetMode":true,"canSetModel":true}},"authMethods":[]}}}}\n' "$id"
|
||||
;;
|
||||
model/list)
|
||||
printf '{{"jsonrpc":"2.0","id":%s,"result":{{"data":[{{"model":"{agent}-model","displayName":"{agent} model","isDefault":true}}],"nextCursor":null}}}}\n' "$id"
|
||||
;;
|
||||
session/new)
|
||||
printf '{{"jsonrpc":"2.0","id":%s,"result":{{"sessionId":"%s","availableModes":[],"configOptions":[]}}}}\n' "$id" "$SESSION_ID"
|
||||
;;
|
||||
session/prompt)
|
||||
printf '{{"jsonrpc":"2.0","method":"session/update","params":{{"sessionId":"%s","update":{{"sessionUpdate":"agent_message_chunk","content":{{"type":"text","text":"{agent}: stub response"}}}}}}}}\n' "$SESSION_ID"
|
||||
printf '{{"jsonrpc":"2.0","id":%s,"result":{{"stopReason":"end_turn"}}}}\n' "$id"
|
||||
;;
|
||||
session/cancel)
|
||||
printf '{{"jsonrpc":"2.0","method":"session/update","params":{{"sessionId":"%s","update":{{"sessionUpdate":"agent_message_chunk","content":{{"type":"text","text":"{agent}: cancelled"}}}}}}}}\n' "$SESSION_ID"
|
||||
;;
|
||||
*)
|
||||
if [ -n "$id" ]; then
|
||||
printf '{{"jsonrpc":"2.0","id":%s,"result":{{}}}}\n' "$id"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
"#
|
||||
);
|
||||
|
||||
fs::write(path, script).expect("write agent process stub");
|
||||
#[cfg(unix)]
|
||||
set_executable(path);
|
||||
}
|
||||
|
||||
fn setup_stub_artifacts(install_dir: &Path, agent: &str) {
|
||||
let native = install_dir.join(agent);
|
||||
write_stub_native(&native, agent);
|
||||
|
||||
let agent_processes = install_dir.join("agent_processes");
|
||||
fs::create_dir_all(&agent_processes).expect("create agent processes dir");
|
||||
let launcher = if cfg!(windows) {
|
||||
agent_processes.join(format!("{agent}-acp.cmd"))
|
||||
} else {
|
||||
agent_processes.join(format!("{agent}-acp"))
|
||||
};
|
||||
write_stub_agent_process(&launcher, agent);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn agent_process_matrix_smoke_and_jsonrpc_conformance() {
|
||||
let agents = ["claude", "codex", "opencode"];
|
||||
let test_app = TestApp::with_setup(|install_dir| {
|
||||
for agent in agents {
|
||||
setup_stub_artifacts(install_dir, agent);
|
||||
}
|
||||
});
|
||||
|
||||
for agent in agents {
|
||||
let initialize = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "1.0",
|
||||
"clientCapabilities": {},
|
||||
"_meta": {
|
||||
"sandboxagent.dev": {
|
||||
"agent": agent
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let (status, init_headers, init_body) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(initialize),
|
||||
&[],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK, "{agent}: initialize status");
|
||||
let init_json = parse_json(&init_body);
|
||||
assert_eq!(init_json["jsonrpc"], "2.0", "{agent}: initialize jsonrpc");
|
||||
assert_eq!(init_json["id"], 1, "{agent}: initialize id");
|
||||
|
||||
let connection_id = init_headers
|
||||
.get("x-acp-connection-id")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.expect("connection id");
|
||||
|
||||
let new_session = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "session/new",
|
||||
"params": {
|
||||
"cwd": "/tmp",
|
||||
"mcpServers": [],
|
||||
"_meta": {
|
||||
"sandboxagent.dev": {
|
||||
"agent": agent
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let (status, _, new_body) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(new_session),
|
||||
&[("x-acp-connection-id", connection_id)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK, "{agent}: session/new status");
|
||||
let new_json = parse_json(&new_body);
|
||||
assert_eq!(new_json["jsonrpc"], "2.0", "{agent}: session/new jsonrpc");
|
||||
assert_eq!(new_json["id"], 2, "{agent}: session/new id");
|
||||
|
||||
let session_id = new_json["result"]["sessionId"]
|
||||
.as_str()
|
||||
.expect("session id");
|
||||
|
||||
let prompt = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 3,
|
||||
"method": "session/prompt",
|
||||
"params": {
|
||||
"sessionId": session_id,
|
||||
"prompt": [{"type": "text", "text": "ping"}]
|
||||
}
|
||||
});
|
||||
let (status, _, prompt_body) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(prompt),
|
||||
&[("x-acp-connection-id", connection_id)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK, "{agent}: prompt status");
|
||||
let prompt_json = parse_json(&prompt_body);
|
||||
assert_eq!(prompt_json["jsonrpc"], "2.0", "{agent}: prompt jsonrpc");
|
||||
assert_eq!(prompt_json["id"], 3, "{agent}: prompt id");
|
||||
assert_eq!(
|
||||
prompt_json["result"]["stopReason"], "end_turn",
|
||||
"{agent}: prompt stop reason"
|
||||
);
|
||||
|
||||
let sse_chunk = read_first_sse_chunk(&test_app.app, connection_id).await;
|
||||
let sse_envelope = parse_sse_data(&sse_chunk);
|
||||
assert_eq!(sse_envelope["jsonrpc"], "2.0", "{agent}: SSE jsonrpc");
|
||||
assert_eq!(
|
||||
sse_envelope["method"], "session/update",
|
||||
"{agent}: SSE method"
|
||||
);
|
||||
assert!(
|
||||
sse_envelope["params"]["update"]["content"]["text"]
|
||||
.as_str()
|
||||
.is_some_and(|text| text.contains(agent)),
|
||||
"{agent}: SSE content text"
|
||||
);
|
||||
|
||||
let (close_status, _, _) = send_request(
|
||||
&test_app.app,
|
||||
Method::DELETE,
|
||||
"/v2/rpc",
|
||||
None,
|
||||
&[("x-acp-connection-id", connection_id)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
close_status,
|
||||
StatusCode::NO_CONTENT,
|
||||
"{agent}: close status"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn one_agent_process_is_shared_across_connections() {
|
||||
let test_app = TestApp::with_setup(|install_dir| {
|
||||
let counter_file = install_dir.join("codex-process-start-count.txt");
|
||||
setup_stub_artifacts(install_dir, "codex");
|
||||
let launcher = install_dir.join("agent_processes").join("codex-acp");
|
||||
write_stub_agent_process_with_counter(&launcher, "codex", Some(&counter_file));
|
||||
});
|
||||
|
||||
let initialize = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "1.0",
|
||||
"clientCapabilities": {},
|
||||
"_meta": {
|
||||
"sandboxagent.dev": {
|
||||
"agent": "codex"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let (status, headers_a, _) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(initialize.clone()),
|
||||
&[],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let connection_a = headers_a
|
||||
.get("x-acp-connection-id")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.expect("connection id")
|
||||
.to_string();
|
||||
|
||||
let (status, headers_b, _) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(initialize),
|
||||
&[],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let connection_b = headers_b
|
||||
.get("x-acp-connection-id")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.expect("connection id")
|
||||
.to_string();
|
||||
|
||||
let new_session = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "session/new",
|
||||
"params": {
|
||||
"cwd": "/tmp",
|
||||
"mcpServers": [],
|
||||
"_meta": {
|
||||
"sandboxagent.dev": {
|
||||
"agent": "codex"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let (status, _, _) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(new_session.clone()),
|
||||
&[("x-acp-connection-id", &connection_a)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
|
||||
let (status, _, _) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(new_session),
|
||||
&[("x-acp-connection-id", &connection_b)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
|
||||
let counter_file = test_app
|
||||
._install_dir
|
||||
.path()
|
||||
.join("codex-process-start-count.txt");
|
||||
let count = fs::read_to_string(counter_file).expect("read process start count file");
|
||||
assert_eq!(count.trim(), "1");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn session_list_is_global_across_agents() {
|
||||
let test_app = TestApp::with_setup(|install_dir| {
|
||||
setup_stub_artifacts(install_dir, "claude");
|
||||
setup_stub_artifacts(install_dir, "codex");
|
||||
});
|
||||
|
||||
let initialize = |id: i64, agent: &str| {
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "1.0",
|
||||
"clientCapabilities": {},
|
||||
"_meta": {
|
||||
"sandboxagent.dev": {
|
||||
"agent": agent
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let (status, claude_headers, _) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(initialize(1, "claude")),
|
||||
&[],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let claude_conn = claude_headers
|
||||
.get("x-acp-connection-id")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.expect("connection id")
|
||||
.to_string();
|
||||
|
||||
let (status, codex_headers, _) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(initialize(2, "codex")),
|
||||
&[],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let codex_conn = codex_headers
|
||||
.get("x-acp-connection-id")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.expect("connection id")
|
||||
.to_string();
|
||||
|
||||
let new_session = |id: i64, agent: &str| {
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"method": "session/new",
|
||||
"params": {
|
||||
"cwd": "/tmp",
|
||||
"mcpServers": [],
|
||||
"_meta": {
|
||||
"sandboxagent.dev": {
|
||||
"agent": agent
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let (status, _, claude_session_body) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(new_session(3, "claude")),
|
||||
&[("x-acp-connection-id", &claude_conn)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let claude_session = parse_json(&claude_session_body)["result"]["sessionId"]
|
||||
.as_str()
|
||||
.expect("claude session")
|
||||
.to_string();
|
||||
|
||||
let (status, _, codex_session_body) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(new_session(4, "codex")),
|
||||
&[("x-acp-connection-id", &codex_conn)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let codex_session = parse_json(&codex_session_body)["result"]["sessionId"]
|
||||
.as_str()
|
||||
.expect("codex session")
|
||||
.to_string();
|
||||
|
||||
let list_request = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 5,
|
||||
"method": "session/list",
|
||||
"params": {}
|
||||
});
|
||||
let (status, _, list_body) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(list_request),
|
||||
&[("x-acp-connection-id", &claude_conn)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let list_json = parse_json(&list_body);
|
||||
let sessions = list_json["result"]["sessions"]
|
||||
.as_array()
|
||||
.expect("sessions");
|
||||
assert!(sessions
|
||||
.iter()
|
||||
.any(|session| session["sessionId"] == claude_session));
|
||||
assert!(sessions
|
||||
.iter()
|
||||
.any(|session| session["sessionId"] == codex_session));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn list_models_extension_uses_non_mock_agent_process() {
|
||||
let test_app = TestApp::with_setup(|install_dir| {
|
||||
setup_stub_artifacts(install_dir, "codex");
|
||||
});
|
||||
|
||||
let initialize = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "1.0",
|
||||
"clientCapabilities": {},
|
||||
"_meta": {
|
||||
"sandboxagent.dev": {
|
||||
"agent": "codex"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let (status, headers, _) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(initialize),
|
||||
&[],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let connection_id = headers
|
||||
.get("x-acp-connection-id")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.expect("connection id")
|
||||
.to_string();
|
||||
|
||||
let list_models = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "_sandboxagent/session/list_models",
|
||||
"params": {
|
||||
"agent": "codex"
|
||||
}
|
||||
});
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(list_models),
|
||||
&[("x-acp-connection-id", &connection_id)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let parsed = parse_json(&body);
|
||||
let models = parsed["result"]["availableModels"]
|
||||
.as_array()
|
||||
.expect("available models");
|
||||
assert!(
|
||||
models.iter().any(|model| model["modelId"] == "codex-model"),
|
||||
"expected codex model in {models:?}"
|
||||
);
|
||||
assert_eq!(parsed["result"]["currentModelId"], "codex-model");
|
||||
}
|
||||
|
|
@ -1,569 +0,0 @@
|
|||
use super::*;
|
||||
#[tokio::test]
|
||||
async fn initialize_advertises_sandbox_extensions() {
|
||||
let test_app = TestApp::new(AuthConfig::disabled());
|
||||
let initialize = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "1.0",
|
||||
"clientCapabilities": {},
|
||||
"_meta": {
|
||||
"sandboxagent.dev": {
|
||||
"agent": "mock"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(initialize),
|
||||
&[],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let parsed = parse_json(&body);
|
||||
let extensions =
|
||||
&parsed["result"]["agentCapabilities"]["_meta"]["sandboxagent.dev"]["extensions"];
|
||||
assert_eq!(extensions["sessionDetach"], true);
|
||||
assert_eq!(extensions["sessionListModels"], true);
|
||||
assert_eq!(extensions["sessionSetMetadata"], true);
|
||||
assert_eq!(extensions["sessionAgentMeta"], true);
|
||||
assert_eq!(extensions["agentList"], true);
|
||||
assert_eq!(extensions["agentInstall"], true);
|
||||
assert_eq!(extensions["sessionList"], true);
|
||||
assert_eq!(extensions["sessionGet"], true);
|
||||
assert_eq!(extensions["fsListEntries"], true);
|
||||
assert_eq!(extensions["fsReadFile"], true);
|
||||
assert_eq!(extensions["fsWriteFile"], true);
|
||||
assert_eq!(extensions["fsDeleteEntry"], true);
|
||||
assert_eq!(extensions["fsMkdir"], true);
|
||||
assert_eq!(extensions["fsMove"], true);
|
||||
assert_eq!(extensions["fsStat"], true);
|
||||
assert_eq!(extensions["fsUploadBatch"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn agent_list_extension_returns_agents() {
|
||||
let test_app = TestApp::new(AuthConfig::disabled());
|
||||
let connection_id = create_mock_connection(&test_app.app, &[]).await;
|
||||
let request = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 8,
|
||||
"method": "_sandboxagent/agent/list",
|
||||
"params": {}
|
||||
});
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(request),
|
||||
&[("x-acp-connection-id", &connection_id)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let parsed = parse_json(&body);
|
||||
let agents = parsed["result"]["agents"].as_array().expect("agents array");
|
||||
assert!(
|
||||
agents.iter().any(|agent| agent["id"] == "mock"),
|
||||
"expected mock agent in {agents:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_get_extension_returns_session() {
|
||||
let test_app = TestApp::new(AuthConfig::disabled());
|
||||
let connection_id = create_mock_connection(&test_app.app, &[]).await;
|
||||
|
||||
let new_session = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 9,
|
||||
"method": "session/new",
|
||||
"params": {
|
||||
"cwd": "/tmp",
|
||||
"mcpServers": [],
|
||||
"_meta": {
|
||||
"sandboxagent.dev": {
|
||||
"agent": "mock"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(new_session),
|
||||
&[("x-acp-connection-id", &connection_id)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let session_id = parse_json(&body)["result"]["sessionId"]
|
||||
.as_str()
|
||||
.expect("session id")
|
||||
.to_string();
|
||||
|
||||
let request = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 10,
|
||||
"method": "_sandboxagent/session/get",
|
||||
"params": {
|
||||
"sessionId": session_id
|
||||
}
|
||||
});
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(request),
|
||||
&[("x-acp-connection-id", &connection_id)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(parse_json(&body)["result"]["sessionId"], session_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_list_models_extension_returns_mock_catalog() {
|
||||
let test_app = TestApp::new(AuthConfig::disabled());
|
||||
let connection_id = create_mock_connection(&test_app.app, &[]).await;
|
||||
|
||||
let request = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 33,
|
||||
"method": "_sandboxagent/session/list_models",
|
||||
"params": {
|
||||
"agent": "mock"
|
||||
}
|
||||
});
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(request),
|
||||
&[("x-acp-connection-id", &connection_id)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let parsed = parse_json(&body);
|
||||
let models = parsed["result"]["availableModels"]
|
||||
.as_array()
|
||||
.expect("available models");
|
||||
assert!(
|
||||
models.iter().any(|model| model["modelId"] == "mock"),
|
||||
"expected mock model in {models:?}"
|
||||
);
|
||||
assert_eq!(parsed["result"]["currentModelId"], "mock");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_set_metadata_extension_updates_session_list_entry() {
|
||||
let test_app = TestApp::new(AuthConfig::disabled());
|
||||
let connection_id = create_mock_connection(&test_app.app, &[]).await;
|
||||
|
||||
let new_session = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "session/new",
|
||||
"params": {
|
||||
"cwd": "/tmp",
|
||||
"mcpServers": [],
|
||||
"_meta": {
|
||||
"sandboxagent.dev": {
|
||||
"agent": "mock",
|
||||
"title": "From Meta",
|
||||
"variant": "high",
|
||||
"requestedSessionId": "alias-1"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(new_session),
|
||||
&[("x-acp-connection-id", &connection_id)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let session_id = parse_json(&body)["result"]["sessionId"]
|
||||
.as_str()
|
||||
.expect("session id")
|
||||
.to_string();
|
||||
|
||||
let update = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 3,
|
||||
"method": "_sandboxagent/session/set_metadata",
|
||||
"params": {
|
||||
"sessionId": session_id,
|
||||
"metadata": {
|
||||
"title": "Updated Title",
|
||||
"permissionMode": "ask",
|
||||
"model": "mock"
|
||||
}
|
||||
}
|
||||
});
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(update),
|
||||
&[("x-acp-connection-id", &connection_id)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(parse_json(&body)["result"], json!({}));
|
||||
|
||||
let list_request = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 4,
|
||||
"method": "session/list",
|
||||
"params": {}
|
||||
});
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(list_request),
|
||||
&[("x-acp-connection-id", &connection_id)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let parsed = parse_json(&body);
|
||||
let sessions = parsed["result"]["sessions"]
|
||||
.as_array()
|
||||
.expect("sessions array");
|
||||
let entry = sessions
|
||||
.iter()
|
||||
.find(|session| session["sessionId"] == session_id)
|
||||
.expect("session entry");
|
||||
assert_eq!(entry["title"], "Updated Title");
|
||||
assert_eq!(entry["_meta"]["sandboxagent.dev"]["variant"], "high");
|
||||
assert_eq!(
|
||||
entry["_meta"]["sandboxagent.dev"]["requestedSessionId"],
|
||||
"alias-1"
|
||||
);
|
||||
assert_eq!(entry["_meta"]["sandboxagent.dev"]["permissionMode"], "ask");
|
||||
assert_eq!(entry["_meta"]["sandboxagent.dev"]["model"], "mock");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_list_is_shared_across_clients() {
|
||||
let test_app = TestApp::new(AuthConfig::disabled());
|
||||
let connection_a = create_mock_connection(&test_app.app, &[]).await;
|
||||
let connection_b = create_mock_connection(&test_app.app, &[]).await;
|
||||
|
||||
let new_session = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "session/new",
|
||||
"params": {
|
||||
"cwd": "/tmp",
|
||||
"mcpServers": [],
|
||||
"_meta": {
|
||||
"sandboxagent.dev": {
|
||||
"agent": "mock"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(new_session),
|
||||
&[("x-acp-connection-id", &connection_a)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let created_session_id = parse_json(&body)["result"]["sessionId"]
|
||||
.as_str()
|
||||
.expect("session id")
|
||||
.to_string();
|
||||
|
||||
let list_request = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 77,
|
||||
"method": "session/list",
|
||||
"params": {}
|
||||
});
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(list_request),
|
||||
&[("x-acp-connection-id", &connection_b)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let parsed = parse_json(&body);
|
||||
let sessions = parsed["result"]["sessions"]
|
||||
.as_array()
|
||||
.expect("sessions array");
|
||||
assert!(
|
||||
sessions
|
||||
.iter()
|
||||
.any(|session| session["sessionId"] == created_session_id),
|
||||
"expected shared session list to include {created_session_id}, got {sessions:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_detach_stops_stream_delivery_for_that_client() {
|
||||
let test_app = TestApp::new(AuthConfig::disabled());
|
||||
let connection_a = create_mock_connection(&test_app.app, &[]).await;
|
||||
let connection_b = create_mock_connection(&test_app.app, &[]).await;
|
||||
|
||||
let new_session = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "session/new",
|
||||
"params": {
|
||||
"cwd": "/tmp",
|
||||
"mcpServers": [],
|
||||
"_meta": {
|
||||
"sandboxagent.dev": {
|
||||
"agent": "mock"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(new_session),
|
||||
&[("x-acp-connection-id", &connection_a)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let session_id = parse_json(&body)["result"]["sessionId"]
|
||||
.as_str()
|
||||
.expect("session id")
|
||||
.to_string();
|
||||
|
||||
let load_session = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 3,
|
||||
"method": "session/load",
|
||||
"params": {
|
||||
"sessionId": session_id,
|
||||
"cwd": "/tmp",
|
||||
"mcpServers": []
|
||||
}
|
||||
});
|
||||
let (status, _, _) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(load_session),
|
||||
&[("x-acp-connection-id", &connection_b)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
|
||||
let leave = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 4,
|
||||
"method": "_sandboxagent/session/detach",
|
||||
"params": {
|
||||
"sessionId": session_id
|
||||
}
|
||||
});
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(leave),
|
||||
&[("x-acp-connection-id", &connection_b)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(parse_json(&body)["result"], json!({}));
|
||||
|
||||
let prompt = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 5,
|
||||
"method": "session/prompt",
|
||||
"params": {
|
||||
"sessionId": session_id,
|
||||
"prompt": [{"type": "text", "text": "hello"}]
|
||||
}
|
||||
});
|
||||
let (status, _, _) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(prompt),
|
||||
&[("x-acp-connection-id", &connection_a)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
|
||||
let got_data = sse_has_data_with_last_id(
|
||||
&test_app.app,
|
||||
&connection_b,
|
||||
10_000,
|
||||
Duration::from_millis(400),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
!got_data,
|
||||
"connection should not receive session updates after leave"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_terminate_extension_is_idempotent_and_emits_session_ended() {
|
||||
let test_app = TestApp::new(AuthConfig::disabled());
|
||||
let connection_id = create_mock_connection(&test_app.app, &[]).await;
|
||||
|
||||
let new_session = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 210,
|
||||
"method": "session/new",
|
||||
"params": {
|
||||
"cwd": "/tmp",
|
||||
"mcpServers": [],
|
||||
"_meta": {
|
||||
"sandboxagent.dev": {
|
||||
"agent": "mock"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(new_session),
|
||||
&[("x-acp-connection-id", &connection_id)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let session_id = parse_json(&body)["result"]["sessionId"]
|
||||
.as_str()
|
||||
.expect("session id")
|
||||
.to_string();
|
||||
|
||||
let terminate = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 211,
|
||||
"method": "_sandboxagent/session/terminate",
|
||||
"params": {
|
||||
"sessionId": session_id
|
||||
}
|
||||
});
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(terminate),
|
||||
&[("x-acp-connection-id", &connection_id)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(parse_json(&body)["result"]["terminated"], true);
|
||||
|
||||
let ended_chunk = read_sse_until_contains(
|
||||
&test_app.app,
|
||||
&connection_id,
|
||||
0,
|
||||
"_sandboxagent/session/ended",
|
||||
6,
|
||||
)
|
||||
.await
|
||||
.expect("expected session ended event");
|
||||
let ended_payload = parse_sse_data(&ended_chunk);
|
||||
assert_eq!(ended_payload["method"], "_sandboxagent/session/ended");
|
||||
assert_eq!(ended_payload["params"]["session_id"], session_id);
|
||||
|
||||
let terminate_again = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 212,
|
||||
"method": "_sandboxagent/session/terminate",
|
||||
"params": {
|
||||
"sessionId": session_id
|
||||
}
|
||||
});
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(terminate_again),
|
||||
&[("x-acp-connection-id", &connection_id)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(parse_json(&body)["result"]["terminated"], false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_acp_detaches_but_does_not_terminate_session() {
|
||||
let test_app = TestApp::new(AuthConfig::disabled());
|
||||
let connection_a = create_mock_connection(&test_app.app, &[]).await;
|
||||
|
||||
let new_session = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 220,
|
||||
"method": "session/new",
|
||||
"params": {
|
||||
"cwd": "/tmp",
|
||||
"mcpServers": [],
|
||||
"_meta": {
|
||||
"sandboxagent.dev": {
|
||||
"agent": "mock"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(new_session),
|
||||
&[("x-acp-connection-id", &connection_a)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let session_id = parse_json(&body)["result"]["sessionId"]
|
||||
.as_str()
|
||||
.expect("session id")
|
||||
.to_string();
|
||||
|
||||
let (status, _, _) = send_request(
|
||||
&test_app.app,
|
||||
Method::DELETE,
|
||||
"/v2/rpc",
|
||||
None,
|
||||
&[("x-acp-connection-id", &connection_a)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NO_CONTENT);
|
||||
|
||||
let connection_b = create_mock_connection(&test_app.app, &[]).await;
|
||||
let load = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 221,
|
||||
"method": "session/load",
|
||||
"params": {
|
||||
"sessionId": session_id,
|
||||
"cwd": "/tmp",
|
||||
"mcpServers": []
|
||||
}
|
||||
});
|
||||
let (status, _, _) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(load),
|
||||
&[("x-acp-connection-id", &connection_b)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
}
|
||||
|
|
@ -1,623 +0,0 @@
|
|||
use super::*;
|
||||
#[tokio::test]
|
||||
async fn acp_mock_prompt_flow_and_replay() {
|
||||
let test_app = TestApp::new(AuthConfig::disabled());
|
||||
let connection_id = create_mock_connection(&test_app.app, &[]).await;
|
||||
|
||||
let new_session = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "session/new",
|
||||
"params": {
|
||||
"cwd": "/tmp",
|
||||
"mcpServers": [],
|
||||
"_meta": {
|
||||
"sandboxagent.dev": {
|
||||
"agent": "mock"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(new_session),
|
||||
&[("x-acp-connection-id", &connection_id)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let session_id = parse_json(&body)["result"]["sessionId"]
|
||||
.as_str()
|
||||
.expect("session id")
|
||||
.to_string();
|
||||
|
||||
let prompt = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 3,
|
||||
"method": "session/prompt",
|
||||
"params": {
|
||||
"sessionId": session_id,
|
||||
"prompt": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "hello"
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(prompt),
|
||||
&[("x-acp-connection-id", &connection_id)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(parse_json(&body)["result"]["stopReason"], "end_turn");
|
||||
|
||||
let sse_chunk = read_first_sse_data(&test_app.app, &connection_id).await;
|
||||
assert!(sse_chunk.contains("session/update"), "{sse_chunk}");
|
||||
assert!(sse_chunk.contains("mock:"), "{sse_chunk}");
|
||||
assert!(!sse_chunk.contains("mock: hello"), "{sse_chunk}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn acp_delete_is_idempotent() {
|
||||
let test_app = TestApp::new(AuthConfig::disabled());
|
||||
let connection_id = create_mock_connection(&test_app.app, &[]).await;
|
||||
|
||||
let (status, _, _) = send_request(
|
||||
&test_app.app,
|
||||
Method::DELETE,
|
||||
"/v2/rpc",
|
||||
None,
|
||||
&[("x-acp-connection-id", &connection_id)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NO_CONTENT);
|
||||
|
||||
let (status, _, _) = send_request(
|
||||
&test_app.app,
|
||||
Method::DELETE,
|
||||
"/v2/rpc",
|
||||
None,
|
||||
&[("x-acp-connection-id", &connection_id)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NO_CONTENT);
|
||||
|
||||
let prompt = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 99,
|
||||
"method": "session/prompt",
|
||||
"params": {
|
||||
"sessionId": "mock-session-1",
|
||||
"prompt": [{"type": "text", "text": "ping"}]
|
||||
}
|
||||
});
|
||||
|
||||
let (status, _, _) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(prompt),
|
||||
&[("x-acp-connection-id", &connection_id)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_cancel_notification_emits_cancelled_update() {
|
||||
let test_app = TestApp::new(AuthConfig::disabled());
|
||||
let connection_id = create_mock_connection(&test_app.app, &[]).await;
|
||||
|
||||
let cancel = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "session/cancel",
|
||||
"params": {
|
||||
"sessionId": "mock-session-1",
|
||||
"agent": "mock"
|
||||
}
|
||||
});
|
||||
|
||||
let (status, _, _) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(cancel),
|
||||
&[("x-acp-connection-id", &connection_id)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::ACCEPTED);
|
||||
|
||||
let sse_chunk = read_first_sse_data(&test_app.app, &connection_id).await;
|
||||
assert!(sse_chunk.contains("session/update"), "{sse_chunk}");
|
||||
assert!(sse_chunk.contains("cancelled"), "{sse_chunk}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hitl_permission_request_round_trip() {
|
||||
let test_app = TestApp::new(AuthConfig::disabled());
|
||||
let connection_id = create_mock_connection(&test_app.app, &[]).await;
|
||||
|
||||
let new_session = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "session/new",
|
||||
"params": {
|
||||
"cwd": "/tmp",
|
||||
"mcpServers": [],
|
||||
"_meta": {
|
||||
"sandboxagent.dev": {
|
||||
"agent": "mock"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(new_session),
|
||||
&[("x-acp-connection-id", &connection_id)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let session_id = parse_json(&body)["result"]["sessionId"]
|
||||
.as_str()
|
||||
.expect("session id")
|
||||
.to_string();
|
||||
|
||||
let prompt = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 3,
|
||||
"method": "session/prompt",
|
||||
"params": {
|
||||
"sessionId": session_id,
|
||||
"prompt": [{"type": "text", "text": "needs permission"}]
|
||||
}
|
||||
});
|
||||
let (status, _, _) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(prompt),
|
||||
&[("x-acp-connection-id", &connection_id)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
|
||||
let sse_chunk = read_first_sse_data(&test_app.app, &connection_id).await;
|
||||
assert!(
|
||||
sse_chunk.contains("session/request_permission"),
|
||||
"{sse_chunk}"
|
||||
);
|
||||
|
||||
let permission_request = parse_sse_data(&sse_chunk);
|
||||
let permission_response = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": permission_request["id"].clone(),
|
||||
"result": {
|
||||
"outcome": {
|
||||
"outcome": "cancelled"
|
||||
}
|
||||
}
|
||||
});
|
||||
let (status, _, _) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(permission_response),
|
||||
&[("x-acp-connection-id", &connection_id)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::ACCEPTED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_acp_envelope_returns_bad_request() {
|
||||
let test_app = TestApp::new(AuthConfig::disabled());
|
||||
let (status, _, body) =
|
||||
send_request(&test_app.app, Method::POST, "/v2/rpc", Some(json!([])), &[]).await;
|
||||
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
let parsed = parse_json(&body);
|
||||
assert_eq!(parsed["status"], 400);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_requires_json_content_type() {
|
||||
let test_app = TestApp::new(AuthConfig::disabled());
|
||||
let payload = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"1.0","clientCapabilities":{}}}"#
|
||||
.to_vec();
|
||||
let (status, _, body) = send_request_raw(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(payload),
|
||||
&[],
|
||||
Some("text/plain"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::UNSUPPORTED_MEDIA_TYPE);
|
||||
assert_eq!(parse_json(&body)["status"], 415);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_rejects_non_json_accept() {
|
||||
let test_app = TestApp::new(AuthConfig::disabled());
|
||||
let payload = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "1.0",
|
||||
"clientCapabilities": {}
|
||||
}
|
||||
});
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(payload),
|
||||
&[("accept", "text/event-stream")],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NOT_ACCEPTABLE);
|
||||
assert_eq!(parse_json(&body)["status"], 406);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_rejects_removed_x_acp_agent_header() {
|
||||
let test_app = TestApp::new(AuthConfig::disabled());
|
||||
let payload = initialize_payload("mock");
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(payload),
|
||||
&[("x-acp-agent", "mock")],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(parse_json(&body)["status"], 400);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_new_requires_sandbox_meta_agent() {
|
||||
let test_app = TestApp::new(AuthConfig::disabled());
|
||||
let connection_id = create_mock_connection(&test_app.app, &[]).await;
|
||||
let new_session = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 42,
|
||||
"method": "session/new",
|
||||
"params": {
|
||||
"cwd": "/tmp",
|
||||
"mcpServers": []
|
||||
}
|
||||
});
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(new_session),
|
||||
&[("x-acp-connection-id", &connection_id)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(parse_json(&body)["status"], 400);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unstable_methods_available_on_mock() {
|
||||
let test_app = TestApp::new(AuthConfig::disabled());
|
||||
let connection_id = create_mock_connection(&test_app.app, &[]).await;
|
||||
|
||||
let methods = [
|
||||
"session/list",
|
||||
"session/fork",
|
||||
"session/resume",
|
||||
"session/set_model",
|
||||
"$/cancel_request",
|
||||
];
|
||||
|
||||
for (index, method) in methods.iter().enumerate() {
|
||||
let request = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": index + 10,
|
||||
"method": method,
|
||||
"params": {
|
||||
"agent": "mock"
|
||||
}
|
||||
});
|
||||
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(request),
|
||||
&[("x-acp-connection-id", &connection_id)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK, "{method}");
|
||||
assert!(parse_json(&body).get("result").is_some(), "{method}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sse_replay_honors_last_event_id() {
|
||||
let test_app = TestApp::new(AuthConfig::disabled());
|
||||
let connection_id = create_mock_connection(&test_app.app, &[]).await;
|
||||
|
||||
let new_session = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "session/new",
|
||||
"params": {
|
||||
"cwd": "/tmp",
|
||||
"mcpServers": [],
|
||||
"_meta": {
|
||||
"sandboxagent.dev": {
|
||||
"agent": "mock"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let (_status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(new_session),
|
||||
&[("x-acp-connection-id", &connection_id)],
|
||||
)
|
||||
.await;
|
||||
let session_id = parse_json(&body)["result"]["sessionId"]
|
||||
.as_str()
|
||||
.expect("session id")
|
||||
.to_string();
|
||||
|
||||
for (id, text) in [(3, "one"), (4, "two")] {
|
||||
let prompt = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"method": "session/prompt",
|
||||
"params": {
|
||||
"sessionId": session_id,
|
||||
"prompt": [{"type": "text", "text": text}]
|
||||
}
|
||||
});
|
||||
let (status, _, _) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(prompt),
|
||||
&[("x-acp-connection-id", &connection_id)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
}
|
||||
|
||||
// First stream event should have id > 1 when replaying from Last-Event-ID=1.
|
||||
let replay_chunk = read_first_sse_data_with_last_id(&test_app.app, &connection_id, 1).await;
|
||||
assert!(
|
||||
replay_chunk.contains("id: 2") || replay_chunk.contains("id: 3"),
|
||||
"{replay_chunk}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_with_unknown_connection_returns_not_found() {
|
||||
let test_app = TestApp::new(AuthConfig::disabled());
|
||||
let request = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 7,
|
||||
"method": "session/new",
|
||||
"params": { "cwd": "/", "mcpServers": [] }
|
||||
});
|
||||
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(request),
|
||||
&[("x-acp-connection-id", "missing-connection")],
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
let parsed = parse_json(&body);
|
||||
assert_eq!(parsed["status"], 404);
|
||||
assert_eq!(parsed["title"], "ACP client not found");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sse_requires_connection_id_header() {
|
||||
let test_app = TestApp::new(AuthConfig::disabled());
|
||||
let (status, _, body) = send_request(&test_app.app, Method::GET, "/v2/rpc", None, &[]).await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(
|
||||
parse_json(&body)["detail"],
|
||||
"invalid request: missing x-acp-connection-id header"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sse_rejects_non_sse_accept() {
|
||||
let test_app = TestApp::new(AuthConfig::disabled());
|
||||
let connection_id = create_mock_connection(&test_app.app, &[]).await;
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::GET,
|
||||
"/v2/rpc",
|
||||
None,
|
||||
&[
|
||||
("x-acp-connection-id", &connection_id),
|
||||
("accept", "application/json"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NOT_ACCEPTABLE);
|
||||
assert_eq!(parse_json(&body)["status"], 406);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sse_single_active_stream_per_connection() {
|
||||
let test_app = TestApp::new(AuthConfig::disabled());
|
||||
let connection_id = create_mock_connection(&test_app.app, &[]).await;
|
||||
|
||||
let first_request = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/v2/rpc")
|
||||
.header("x-acp-connection-id", connection_id.as_str())
|
||||
.header("accept", "text/event-stream")
|
||||
.body(Body::empty())
|
||||
.expect("build first sse request");
|
||||
let first_response = test_app
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(first_request)
|
||||
.await
|
||||
.expect("first sse response");
|
||||
assert_eq!(first_response.status(), StatusCode::OK);
|
||||
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::GET,
|
||||
"/v2/rpc",
|
||||
None,
|
||||
&[
|
||||
("x-acp-connection-id", connection_id.as_str()),
|
||||
("accept", "text/event-stream"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::CONFLICT);
|
||||
assert_eq!(parse_json(&body)["status"], 409);
|
||||
|
||||
drop(first_response);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_requires_connection_id_header() {
|
||||
let test_app = TestApp::new(AuthConfig::disabled());
|
||||
let (status, _, body) = send_request(&test_app.app, Method::DELETE, "/v2/rpc", None, &[]).await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(
|
||||
parse_json(&body)["detail"],
|
||||
"invalid request: missing x-acp-connection-id header"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_last_event_id_returns_bad_request() {
|
||||
let test_app = TestApp::new(AuthConfig::disabled());
|
||||
let connection_id = create_mock_connection(&test_app.app, &[]).await;
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::GET,
|
||||
"/v2/rpc",
|
||||
None,
|
||||
&[
|
||||
("x-acp-connection-id", &connection_id),
|
||||
("last-event-id", "not-a-number"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(
|
||||
parse_json(&body)["detail"],
|
||||
"invalid request: Last-Event-ID must be a positive integer"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn agent_process_request_timeout_maps_to_gateway_timeout() {
|
||||
let test_app = {
|
||||
let _timeout = EnvVarGuard::set("SANDBOX_AGENT_ACP_REQUEST_TIMEOUT_MS", "75");
|
||||
let _close_grace = EnvVarGuard::set("SANDBOX_AGENT_ACP_CLOSE_GRACE_MS", "10");
|
||||
TestApp::with_setup(AuthConfig::disabled(), |install_path| {
|
||||
fs::create_dir_all(install_path.join("agent_processes"))
|
||||
.expect("create agent processes dir");
|
||||
write_executable(&install_path.join("codex"), "#!/usr/bin/env sh\nexit 0\n");
|
||||
write_executable(
|
||||
&install_path.join("agent_processes").join("codex-acp"),
|
||||
"#!/usr/bin/env sh\nwhile IFS= read -r _line; do sleep 10; done\n",
|
||||
);
|
||||
})
|
||||
};
|
||||
|
||||
assert!(test_app.install_path().exists(), "install dir should exist");
|
||||
|
||||
let initialize_notification = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "1.0",
|
||||
"clientCapabilities": {},
|
||||
"_meta": {
|
||||
"sandboxagent.dev": {
|
||||
"agent": "codex"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let (status, headers, _) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(initialize_notification),
|
||||
&[],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::ACCEPTED);
|
||||
|
||||
let connection_id = headers
|
||||
.get("x-acp-connection-id")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.expect("connection id");
|
||||
|
||||
let request = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "session/new",
|
||||
"params": {
|
||||
"cwd": "/tmp",
|
||||
"mcpServers": [],
|
||||
"_meta": {
|
||||
"sandboxagent.dev": {
|
||||
"agent": "codex"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(request),
|
||||
&[("x-acp-connection-id", connection_id)],
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(status, StatusCode::GATEWAY_TIMEOUT);
|
||||
assert_eq!(parse_json(&body)["status"], 504);
|
||||
|
||||
let (close_status, _, _) = send_request(
|
||||
&test_app.app,
|
||||
Method::DELETE,
|
||||
"/v2/rpc",
|
||||
None,
|
||||
&[("x-acp-connection-id", connection_id)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(close_status, StatusCode::NO_CONTENT);
|
||||
}
|
||||
|
|
@ -1,366 +0,0 @@
|
|||
use super::*;
|
||||
#[tokio::test]
|
||||
async fn v2_health_and_v1_removed() {
|
||||
let test_app = TestApp::new(AuthConfig::disabled());
|
||||
|
||||
let (status, _, body) = send_request(&test_app.app, Method::GET, "/v2/health", None, &[]).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(parse_json(&body)["status"], "ok");
|
||||
|
||||
let (status, _, body) =
|
||||
send_request(&test_app.app, Method::GET, "/v1/anything", None, &[]).await;
|
||||
assert_eq!(status, StatusCode::GONE);
|
||||
assert_eq!(parse_json(&body)["detail"], "v1 API removed; use /v2");
|
||||
|
||||
let (status, _, body) =
|
||||
send_request(&test_app.app, Method::GET, "/opencode/session", None, &[]).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(parse_json(&body).is_array());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn v2_sessions_http_endpoints_removed_and_acp_extensions_work() {
|
||||
let test_app = TestApp::new(AuthConfig::disabled());
|
||||
let connection_id = create_mock_connection(&test_app.app, &[]).await;
|
||||
|
||||
let new_session = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 100,
|
||||
"method": "session/new",
|
||||
"params": {
|
||||
"cwd": "/tmp",
|
||||
"mcpServers": [],
|
||||
"_meta": {
|
||||
"sandboxagent.dev": {
|
||||
"agent": "mock"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(new_session),
|
||||
&[("x-acp-connection-id", &connection_id)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let session_id = parse_json(&body)["result"]["sessionId"]
|
||||
.as_str()
|
||||
.expect("session id")
|
||||
.to_string();
|
||||
|
||||
let (status, _, _) = send_request(&test_app.app, Method::GET, "/v2/sessions", None, &[]).await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
|
||||
let (status, _, _) = send_request(
|
||||
&test_app.app,
|
||||
Method::GET,
|
||||
&format!("/v2/sessions/{session_id}"),
|
||||
None,
|
||||
&[],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
|
||||
let list_request = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 101,
|
||||
"method": "_sandboxagent/session/list",
|
||||
"params": {}
|
||||
});
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(list_request),
|
||||
&[("x-acp-connection-id", &connection_id)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let parsed = parse_json(&body);
|
||||
let sessions = parsed["result"]["sessions"].as_array().expect("sessions");
|
||||
assert!(
|
||||
sessions
|
||||
.iter()
|
||||
.any(|entry| entry["sessionId"] == session_id),
|
||||
"expected listed session in {sessions:?}"
|
||||
);
|
||||
|
||||
let get_request = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 102,
|
||||
"method": "_sandboxagent/session/get",
|
||||
"params": {
|
||||
"sessionId": session_id
|
||||
}
|
||||
});
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(get_request),
|
||||
&[("x-acp-connection-id", &connection_id)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let parsed = parse_json(&body);
|
||||
assert_eq!(parsed["result"]["sessionId"], session_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn v2_filesystem_endpoints_round_trip() {
|
||||
let test_app = TestApp::new(AuthConfig::disabled());
|
||||
let connection_id = create_mock_connection(&test_app.app, &[]).await;
|
||||
|
||||
let new_session = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 200,
|
||||
"method": "session/new",
|
||||
"params": {
|
||||
"cwd": "/tmp",
|
||||
"mcpServers": [],
|
||||
"_meta": {
|
||||
"sandboxagent.dev": {
|
||||
"agent": "mock"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(new_session),
|
||||
&[("x-acp-connection-id", &connection_id)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let session_id = parse_json(&body)["result"]["sessionId"]
|
||||
.as_str()
|
||||
.expect("session id")
|
||||
.to_string();
|
||||
|
||||
let (status, _, _) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
&format!("/v2/fs/mkdir?path=docs&session_id={session_id}"),
|
||||
None,
|
||||
&[],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
|
||||
let mkdir_request = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 201,
|
||||
"method": "_sandboxagent/fs/mkdir",
|
||||
"params": {
|
||||
"path": "docs",
|
||||
"sessionId": session_id
|
||||
}
|
||||
});
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(mkdir_request),
|
||||
&[("x-acp-connection-id", &connection_id)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(parse_json(&body)["result"]["path"].is_string());
|
||||
|
||||
let (status, _, body) = send_request_raw(
|
||||
&test_app.app,
|
||||
Method::PUT,
|
||||
&format!("/v2/fs/file?path=docs/file.txt&session_id={session_id}"),
|
||||
Some(b"hello".to_vec()),
|
||||
&[],
|
||||
Some("application/octet-stream"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(parse_json(&body)["bytesWritten"], 5);
|
||||
|
||||
let (status, headers, body) = send_request_raw(
|
||||
&test_app.app,
|
||||
Method::GET,
|
||||
&format!("/v2/fs/file?path=docs/file.txt&session_id={session_id}"),
|
||||
None,
|
||||
&[],
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(
|
||||
headers
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or(""),
|
||||
"application/octet-stream"
|
||||
);
|
||||
assert_eq!(String::from_utf8_lossy(&body), "hello");
|
||||
|
||||
let stat_request = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 202,
|
||||
"method": "_sandboxagent/fs/stat",
|
||||
"params": {
|
||||
"path": "docs/file.txt",
|
||||
"sessionId": session_id
|
||||
}
|
||||
});
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(stat_request),
|
||||
&[("x-acp-connection-id", &connection_id)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(parse_json(&body)["result"]["entryType"], "file");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn v2_auth_enforced_when_token_configured() {
|
||||
let test_app = TestApp::new(AuthConfig::with_token("secret-token".to_string()));
|
||||
|
||||
let (status, _, _) = send_request(&test_app.app, Method::GET, "/v2/health", None, &[]).await;
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::GET,
|
||||
"/v2/health",
|
||||
None,
|
||||
&[("authorization", "Bearer secret-token")],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(parse_json(&body)["status"], "ok");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn require_preinstall_blocks_missing_agent() {
|
||||
let test_app = {
|
||||
let _preinstall = EnvVarGuard::set("SANDBOX_AGENT_REQUIRE_PREINSTALL", "true");
|
||||
TestApp::new(AuthConfig::disabled())
|
||||
};
|
||||
|
||||
let initialize = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "1.0",
|
||||
"clientCapabilities": {},
|
||||
"_meta": {
|
||||
"sandboxagent.dev": {
|
||||
"agent": "codex"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let (status, _, body) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(initialize),
|
||||
&[],
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
let parsed = parse_json(&body);
|
||||
assert_eq!(parsed["status"], 404);
|
||||
assert_eq!(parsed["title"], "Agent Not Installed");
|
||||
}
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn lazy_install_runs_on_first_initialize() {
|
||||
let registry_url = serve_registry_once(json!({
|
||||
"agents": [
|
||||
{
|
||||
"id": "codex-acp",
|
||||
"version": "1.2.3",
|
||||
"distribution": {
|
||||
"npx": {
|
||||
"package": "@example/codex-acp@1.2.3",
|
||||
"args": [],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}));
|
||||
|
||||
let _registry = EnvVarGuard::set("SANDBOX_AGENT_ACP_REGISTRY_URL", ®istry_url);
|
||||
let test_app = TestApp::with_setup(AuthConfig::disabled(), |install_path| {
|
||||
fs::create_dir_all(install_path.join("agent_processes"))
|
||||
.expect("create agent processes dir");
|
||||
write_executable(&install_path.join("codex"), "#!/usr/bin/env sh\nexit 0\n");
|
||||
fs::create_dir_all(install_path.join("bin")).expect("create bin dir");
|
||||
write_executable(
|
||||
&install_path.join("bin").join("npx"),
|
||||
"#!/usr/bin/env sh\nwhile IFS= read -r _line; do :; done\n",
|
||||
);
|
||||
});
|
||||
|
||||
let original_path = std::env::var_os("PATH").unwrap_or_default();
|
||||
let mut paths = vec![test_app.install_path().join("bin")];
|
||||
paths.extend(std::env::split_paths(&original_path));
|
||||
let merged_path = std::env::join_paths(paths).expect("join PATH");
|
||||
let _path_guard = EnvVarGuard::set_os("PATH", merged_path.as_os_str());
|
||||
|
||||
let initialize_notification = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "1.0",
|
||||
"clientCapabilities": {},
|
||||
"_meta": {
|
||||
"sandboxagent.dev": {
|
||||
"agent": "codex"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let (status, headers, _) = send_request(
|
||||
&test_app.app,
|
||||
Method::POST,
|
||||
"/v2/rpc",
|
||||
Some(initialize_notification),
|
||||
&[],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::ACCEPTED);
|
||||
|
||||
let launcher_path = test_app
|
||||
.install_path()
|
||||
.join("agent_processes")
|
||||
.join("codex-acp");
|
||||
assert!(
|
||||
launcher_path.exists(),
|
||||
"expected lazy install to create agent process launcher"
|
||||
);
|
||||
|
||||
let connection_id = headers
|
||||
.get("x-acp-connection-id")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.expect("connection id");
|
||||
let (close_status, _, _) = send_request(
|
||||
&test_app.app,
|
||||
Method::DELETE,
|
||||
"/v2/rpc",
|
||||
None,
|
||||
&[("x-acp-connection-id", connection_id)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(close_status, StatusCode::NO_CONTENT);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue