mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 09:01:17 +00:00
feat: wire formatter discovery and LSP status endpoints
This commit is contained in:
parent
7378abee46
commit
1ce03d078b
8 changed files with 470 additions and 5 deletions
1
.turbo
Symbolic link
1
.turbo
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
/home/nathan/sandbox-agent/.turbo
|
||||||
1
dist
Symbolic link
1
dist
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
/home/nathan/sandbox-agent/dist
|
||||||
1
node_modules
Symbolic link
1
node_modules
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
/home/nathan/sandbox-agent/node_modules
|
||||||
|
|
@ -2487,8 +2487,18 @@ async fn oc_log() -> impl IntoResponse {
|
||||||
responses((status = 200)),
|
responses((status = 200)),
|
||||||
tag = "opencode"
|
tag = "opencode"
|
||||||
)]
|
)]
|
||||||
async fn oc_lsp_status() -> impl IntoResponse {
|
async fn oc_lsp_status(
|
||||||
(StatusCode::OK, Json(json!([])))
|
State(state): State<Arc<OpenCodeAppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Query(query): Query<DirectoryQuery>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let directory = state.opencode.directory_for(&headers, query.directory.as_ref());
|
||||||
|
let statuses = state
|
||||||
|
.inner
|
||||||
|
.session_manager()
|
||||||
|
.lsp_status_for_directory(&directory)
|
||||||
|
.await;
|
||||||
|
(StatusCode::OK, Json(statuses))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
|
|
@ -2497,8 +2507,18 @@ async fn oc_lsp_status() -> impl IntoResponse {
|
||||||
responses((status = 200)),
|
responses((status = 200)),
|
||||||
tag = "opencode"
|
tag = "opencode"
|
||||||
)]
|
)]
|
||||||
async fn oc_formatter_status() -> impl IntoResponse {
|
async fn oc_formatter_status(
|
||||||
(StatusCode::OK, Json(json!([])))
|
State(state): State<Arc<OpenCodeAppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Query(query): Query<DirectoryQuery>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let directory = state.opencode.directory_for(&headers, query.directory.as_ref());
|
||||||
|
let statuses = state
|
||||||
|
.inner
|
||||||
|
.session_manager()
|
||||||
|
.formatter_status_for_directory(&directory)
|
||||||
|
.await;
|
||||||
|
(StatusCode::OK, Json(statuses))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ use std::collections::{HashMap, HashSet, VecDeque};
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
use std::io::{BufRead, BufReader, Write};
|
use std::io::{BufRead, BufReader, Write};
|
||||||
use std::net::TcpListener;
|
use std::net::TcpListener;
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Stdio;
|
use std::process::Stdio;
|
||||||
use std::sync::atomic::{AtomicI64, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicI64, AtomicU64, Ordering};
|
||||||
use std::sync::{Arc, Weak};
|
use std::sync::{Arc, Weak};
|
||||||
|
|
@ -812,12 +812,270 @@ struct AgentServerManager {
|
||||||
restart_notifier: Mutex<Option<mpsc::UnboundedSender<AgentId>>>,
|
restart_notifier: Mutex<Option<mpsc::UnboundedSender<AgentId>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
struct FormatterStatus {
|
||||||
|
name: String,
|
||||||
|
extensions: Vec<String>,
|
||||||
|
enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
struct LspStatus {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
root: String,
|
||||||
|
status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct FormatterDefinition {
|
||||||
|
name: &'static str,
|
||||||
|
extensions: &'static [&'static str],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct LspDefinition {
|
||||||
|
id: &'static str,
|
||||||
|
name: &'static str,
|
||||||
|
extensions: &'static [&'static str],
|
||||||
|
binaries: &'static [&'static str],
|
||||||
|
}
|
||||||
|
|
||||||
|
const FORMATTER_DEFINITIONS: &[FormatterDefinition] = &[
|
||||||
|
FormatterDefinition {
|
||||||
|
name: "prettier",
|
||||||
|
extensions: &[
|
||||||
|
"js", "jsx", "ts", "tsx", "json", "css", "scss", "html", "md", "mdx", "yaml", "yml",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
FormatterDefinition {
|
||||||
|
name: "rustfmt",
|
||||||
|
extensions: &["rs"],
|
||||||
|
},
|
||||||
|
FormatterDefinition {
|
||||||
|
name: "gofmt",
|
||||||
|
extensions: &["go"],
|
||||||
|
},
|
||||||
|
FormatterDefinition {
|
||||||
|
name: "black",
|
||||||
|
extensions: &["py"],
|
||||||
|
},
|
||||||
|
FormatterDefinition {
|
||||||
|
name: "stylua",
|
||||||
|
extensions: &["lua"],
|
||||||
|
},
|
||||||
|
FormatterDefinition {
|
||||||
|
name: "clang-format",
|
||||||
|
extensions: &["c", "h", "cc", "cpp", "cxx", "hpp"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const LSP_DEFINITIONS: &[LspDefinition] = &[
|
||||||
|
LspDefinition {
|
||||||
|
id: "typescript-language-server",
|
||||||
|
name: "TypeScript Language Server",
|
||||||
|
extensions: &["js", "jsx", "ts", "tsx"],
|
||||||
|
binaries: &["typescript-language-server"],
|
||||||
|
},
|
||||||
|
LspDefinition {
|
||||||
|
id: "rust-analyzer",
|
||||||
|
name: "Rust Analyzer",
|
||||||
|
extensions: &["rs"],
|
||||||
|
binaries: &["rust-analyzer"],
|
||||||
|
},
|
||||||
|
LspDefinition {
|
||||||
|
id: "gopls",
|
||||||
|
name: "gopls",
|
||||||
|
extensions: &["go"],
|
||||||
|
binaries: &["gopls"],
|
||||||
|
},
|
||||||
|
LspDefinition {
|
||||||
|
id: "pyright",
|
||||||
|
name: "Pyright",
|
||||||
|
extensions: &["py"],
|
||||||
|
binaries: &["pyright-langserver", "pylsp"],
|
||||||
|
},
|
||||||
|
LspDefinition {
|
||||||
|
id: "lua-language-server",
|
||||||
|
name: "Lua Language Server",
|
||||||
|
extensions: &["lua"],
|
||||||
|
binaries: &["lua-language-server"],
|
||||||
|
},
|
||||||
|
LspDefinition {
|
||||||
|
id: "clangd",
|
||||||
|
name: "clangd",
|
||||||
|
extensions: &["c", "h", "cc", "cpp", "cxx", "hpp"],
|
||||||
|
binaries: &["clangd"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct FormatterService {
|
||||||
|
max_files: usize,
|
||||||
|
max_depth: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FormatterService {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
max_files: 5000,
|
||||||
|
max_depth: 25,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status_for_directory(&self, directory: &Path) -> Vec<FormatterStatus> {
|
||||||
|
let extensions = collect_workspace_extensions(directory, self.max_files, self.max_depth);
|
||||||
|
FORMATTER_DEFINITIONS
|
||||||
|
.iter()
|
||||||
|
.filter(|definition| {
|
||||||
|
definition
|
||||||
|
.extensions
|
||||||
|
.iter()
|
||||||
|
.any(|ext| extensions.contains(*ext))
|
||||||
|
})
|
||||||
|
.map(|definition| FormatterStatus {
|
||||||
|
name: definition.name.to_string(),
|
||||||
|
extensions: definition
|
||||||
|
.extensions
|
||||||
|
.iter()
|
||||||
|
.map(|ext| ext.to_string())
|
||||||
|
.collect(),
|
||||||
|
enabled: true,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct LspStatusRegistry {
|
||||||
|
entries: Mutex<HashMap<String, Vec<LspStatus>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LspStatusRegistry {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
entries: Mutex::new(HashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update(&self, root: &str, statuses: Vec<LspStatus>) -> Vec<LspStatus> {
|
||||||
|
let mut entries = self.entries.lock().await;
|
||||||
|
entries.insert(root.to_string(), statuses.clone());
|
||||||
|
statuses
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_workspace_extensions(
|
||||||
|
root: &Path,
|
||||||
|
max_files: usize,
|
||||||
|
max_depth: usize,
|
||||||
|
) -> HashSet<String> {
|
||||||
|
let mut extensions = HashSet::new();
|
||||||
|
if !root.exists() {
|
||||||
|
return extensions;
|
||||||
|
}
|
||||||
|
let mut queue: VecDeque<(PathBuf, usize)> = VecDeque::new();
|
||||||
|
queue.push_back((root.to_path_buf(), 0));
|
||||||
|
let mut files_scanned = 0usize;
|
||||||
|
while let Some((directory, depth)) = queue.pop_front() {
|
||||||
|
if depth > max_depth {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let entries = match std::fs::read_dir(&directory) {
|
||||||
|
Ok(entries) => entries,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
if files_scanned >= max_files {
|
||||||
|
return extensions;
|
||||||
|
}
|
||||||
|
let file_type = match entry.file_type() {
|
||||||
|
Ok(file_type) => file_type,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
if file_type.is_symlink() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let path = entry.path();
|
||||||
|
if file_type.is_dir() {
|
||||||
|
if let Some(name) = path.file_name().and_then(|value| value.to_str()) {
|
||||||
|
if should_skip_dir(name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
queue.push_back((path, depth + 1));
|
||||||
|
} else if file_type.is_file() {
|
||||||
|
files_scanned += 1;
|
||||||
|
if let Some(ext) = path.extension().and_then(|value| value.to_str()) {
|
||||||
|
extensions.insert(ext.to_ascii_lowercase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
extensions
|
||||||
|
}
|
||||||
|
|
||||||
|
fn should_skip_dir(name: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
name,
|
||||||
|
".git"
|
||||||
|
| "node_modules"
|
||||||
|
| "target"
|
||||||
|
| "dist"
|
||||||
|
| "build"
|
||||||
|
| ".venv"
|
||||||
|
| ".idea"
|
||||||
|
| ".vscode"
|
||||||
|
| ".cargo"
|
||||||
|
| ".turbo"
|
||||||
|
| ".next"
|
||||||
|
| "out"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lsp_binary_available(definition: &LspDefinition) -> bool {
|
||||||
|
definition
|
||||||
|
.binaries
|
||||||
|
.iter()
|
||||||
|
.any(|binary| command_on_path(binary))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn command_candidates(command: &str) -> Vec<String> {
|
||||||
|
if command.ends_with(".exe") {
|
||||||
|
vec![command.to_string()]
|
||||||
|
} else {
|
||||||
|
vec![format!("{command}.exe"), command.to_string()]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn command_candidates(command: &str) -> Vec<String> {
|
||||||
|
vec![command.to_string()]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command_on_path(command: &str) -> bool {
|
||||||
|
let Some(paths) = std::env::var_os("PATH") else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
for dir in std::env::split_paths(&paths) {
|
||||||
|
for candidate in command_candidates(command) {
|
||||||
|
if dir.join(&candidate).is_file() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub(crate) struct SessionManager {
|
pub(crate) struct SessionManager {
|
||||||
agent_manager: Arc<AgentManager>,
|
agent_manager: Arc<AgentManager>,
|
||||||
sessions: Mutex<Vec<SessionState>>,
|
sessions: Mutex<Vec<SessionState>>,
|
||||||
server_manager: Arc<AgentServerManager>,
|
server_manager: Arc<AgentServerManager>,
|
||||||
http_client: Client,
|
http_client: Client,
|
||||||
|
formatter_service: FormatterService,
|
||||||
|
lsp_registry: LspStatusRegistry,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shared Codex app-server process that handles multiple sessions via JSON-RPC.
|
/// Shared Codex app-server process that handles multiple sessions via JSON-RPC.
|
||||||
|
|
@ -1538,9 +1796,45 @@ impl SessionManager {
|
||||||
sessions: Mutex::new(Vec::new()),
|
sessions: Mutex::new(Vec::new()),
|
||||||
server_manager,
|
server_manager,
|
||||||
http_client: Client::new(),
|
http_client: Client::new(),
|
||||||
|
formatter_service: FormatterService::new(),
|
||||||
|
lsp_registry: LspStatusRegistry::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn formatter_status_for_directory(
|
||||||
|
&self,
|
||||||
|
directory: &str,
|
||||||
|
) -> Vec<FormatterStatus> {
|
||||||
|
let root = Path::new(directory);
|
||||||
|
self.formatter_service.status_for_directory(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn lsp_status_for_directory(&self, directory: &str) -> Vec<LspStatus> {
|
||||||
|
let root = Path::new(directory);
|
||||||
|
let extensions = collect_workspace_extensions(root, 5000, 25);
|
||||||
|
let root_string = root.to_string_lossy().to_string();
|
||||||
|
let statuses = LSP_DEFINITIONS
|
||||||
|
.iter()
|
||||||
|
.filter(|definition| {
|
||||||
|
definition
|
||||||
|
.extensions
|
||||||
|
.iter()
|
||||||
|
.any(|ext| extensions.contains(*ext))
|
||||||
|
})
|
||||||
|
.map(|definition| LspStatus {
|
||||||
|
id: definition.id.to_string(),
|
||||||
|
name: definition.name.to_string(),
|
||||||
|
root: root_string.clone(),
|
||||||
|
status: if lsp_binary_available(definition) {
|
||||||
|
"connected".to_string()
|
||||||
|
} else {
|
||||||
|
"error".to_string()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
self.lsp_registry.update(&root_string, statuses).await
|
||||||
|
}
|
||||||
|
|
||||||
fn session_ref<'a>(sessions: &'a [SessionState], session_id: &str) -> Option<&'a SessionState> {
|
fn session_ref<'a>(sessions: &'a [SessionState], session_id: &str) -> Option<&'a SessionState> {
|
||||||
sessions
|
sessions
|
||||||
.iter()
|
.iter()
|
||||||
|
|
|
||||||
146
server/packages/sandbox-agent/tests/http/opencode_endpoints.rs
Normal file
146
server/packages/sandbox-agent/tests/http/opencode_endpoints.rs
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
include!("../common/http.rs");
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::env;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
struct PathGuard {
|
||||||
|
old_path: Option<std::ffi::OsString>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PathGuard {
|
||||||
|
fn new(bin_dir: &Path) -> Self {
|
||||||
|
let old_path = env::var_os("PATH");
|
||||||
|
let mut paths = vec![bin_dir.to_path_buf()];
|
||||||
|
if let Some(existing) = &old_path {
|
||||||
|
paths.extend(env::split_paths(existing));
|
||||||
|
}
|
||||||
|
let joined = env::join_paths(paths).expect("join PATH");
|
||||||
|
env::set_var("PATH", &joined);
|
||||||
|
Self { old_path }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for PathGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Some(value) = &self.old_path {
|
||||||
|
env::set_var("PATH", value);
|
||||||
|
} else {
|
||||||
|
env::remove_var("PATH");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn binary_filename(name: &str) -> String {
|
||||||
|
format!("{name}.exe")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn binary_filename(name: &str) -> String {
|
||||||
|
name.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_executable(path: &Path) {
|
||||||
|
fs::write(path, "").expect("write fake binary");
|
||||||
|
#[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 permissions");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_fake_binary(dir: &Path, name: &str) {
|
||||||
|
let path = dir.join(binary_filename(name));
|
||||||
|
write_executable(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_fixture_file(root: &Path, name: &str) {
|
||||||
|
let path = root.join(name);
|
||||||
|
fs::write(path, "test").expect("write fixture");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_names(value: &serde_json::Value, field: &str) -> HashSet<String> {
|
||||||
|
value
|
||||||
|
.as_array()
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|item| item.get(field).and_then(|value| value.as_str()))
|
||||||
|
.map(|value| value.to_string())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn opencode_formatter_lsp_status_for_workspace() {
|
||||||
|
let fixture_dir = tempfile::tempdir().expect("fixture dir");
|
||||||
|
create_fixture_file(fixture_dir.path(), "main.rs");
|
||||||
|
create_fixture_file(fixture_dir.path(), "main.ts");
|
||||||
|
create_fixture_file(fixture_dir.path(), "main.py");
|
||||||
|
create_fixture_file(fixture_dir.path(), "main.go");
|
||||||
|
|
||||||
|
let bin_dir = tempfile::tempdir().expect("bin dir");
|
||||||
|
add_fake_binary(bin_dir.path(), "rust-analyzer");
|
||||||
|
add_fake_binary(bin_dir.path(), "typescript-language-server");
|
||||||
|
add_fake_binary(bin_dir.path(), "pyright-langserver");
|
||||||
|
add_fake_binary(bin_dir.path(), "gopls");
|
||||||
|
let _guard = PathGuard::new(bin_dir.path());
|
||||||
|
|
||||||
|
let app = TestApp::new();
|
||||||
|
let directory = fixture_dir
|
||||||
|
.path()
|
||||||
|
.to_str()
|
||||||
|
.expect("fixture dir path");
|
||||||
|
|
||||||
|
let formatter_request = Request::builder()
|
||||||
|
.method(Method::GET)
|
||||||
|
.uri("/opencode/formatter")
|
||||||
|
.header("x-opencode-directory", directory)
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("formatter request");
|
||||||
|
let (status, _headers, payload) = send_json_request(&app.app, formatter_request).await;
|
||||||
|
assert_eq!(status, StatusCode::OK, "formatter status");
|
||||||
|
|
||||||
|
let formatter_names = collect_names(&payload, "name");
|
||||||
|
for expected in ["prettier", "rustfmt", "gofmt", "black"] {
|
||||||
|
assert!(
|
||||||
|
formatter_names.contains(expected),
|
||||||
|
"expected formatter {expected}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let lsp_request = Request::builder()
|
||||||
|
.method(Method::GET)
|
||||||
|
.uri("/opencode/lsp")
|
||||||
|
.header("x-opencode-directory", directory)
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("lsp request");
|
||||||
|
let (status, _headers, payload) = send_json_request(&app.app, lsp_request).await;
|
||||||
|
assert_eq!(status, StatusCode::OK, "lsp status");
|
||||||
|
|
||||||
|
let lsp_ids = collect_names(&payload, "id");
|
||||||
|
for expected in [
|
||||||
|
"typescript-language-server",
|
||||||
|
"rust-analyzer",
|
||||||
|
"gopls",
|
||||||
|
"pyright",
|
||||||
|
] {
|
||||||
|
assert!(lsp_ids.contains(expected), "expected lsp {expected}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let statuses: HashSet<String> = payload
|
||||||
|
.as_array()
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|item| item.get("status").and_then(|value| value.as_str()))
|
||||||
|
.map(|value| value.to_string())
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
statuses.iter().all(|value| value == "connected"),
|
||||||
|
"expected all LSPs connected"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
#[path = "http/agent_endpoints.rs"]
|
#[path = "http/agent_endpoints.rs"]
|
||||||
mod agent_endpoints;
|
mod agent_endpoints;
|
||||||
|
mod opencode_endpoints;
|
||||||
|
|
|
||||||
1
target
Symbolic link
1
target
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
/home/nathan/sandbox-agent/target
|
||||||
Loading…
Add table
Add a link
Reference in a new issue