diff --git a/docs/conversion.mdx b/docs/conversion.mdx index d155ab4..4363e06 100644 --- a/docs/conversion.mdx +++ b/docs/conversion.mdx @@ -74,7 +74,7 @@ Policy: Message normalization notes - user vs assistant: normalized via role in the universal item; provider role fields or item types determine role. -- file artifacts: always represented as content parts (type=file_ref) inside message/tool_result items, not a separate item kind. +- file artifacts: always represented as content parts (type=file_ref) inside message/tool_result items, not a separate item kind. Rename actions use `target_path`. - reasoning: represented as content parts (type=reasoning) inside message items, with visibility when available. - subagents: OpenCode subtask parts and Claude Task tool usage are currently normalized into standard message/tool flow (no dedicated subagent fields). - OpenCode unrolling: message.updated creates/updates the parent message item; tool-related parts emit separate tool item events (item.started/ item.completed) with parent_id pointing to the message item. diff --git a/docs/opencode-compatibility.mdx b/docs/opencode-compatibility.mdx index 563d8d6..c0ac2eb 100644 --- a/docs/opencode-compatibility.mdx +++ b/docs/opencode-compatibility.mdx @@ -17,8 +17,8 @@ Authentication matches `/v1`: if a token is configured, requests must include `A | GET | /config/providers | Stubbed | Returns/echoes config payloads. | E2E: openapi-coverage | | GET | /event | SSE stub | Emits compat events for session/message/pty updates only. | E2E: openapi-coverage, events | | GET | /experimental/resource | Stubbed | Experimental endpoints return empty stubs. | E2E: openapi-coverage | -| GET | /experimental/tool | Stubbed | Experimental endpoints return empty stubs. | E2E: openapi-coverage | -| GET | /experimental/tool/ids | Stubbed | Experimental endpoints return empty stubs. | E2E: openapi-coverage | +| GET | /experimental/tool | Derived | Returns MCP tool metadata for connected servers. | E2E: openapi-coverage, opencode-mcp | +| GET | /experimental/tool/ids | Derived | Returns MCP tool IDs for connected servers. | E2E: openapi-coverage, opencode-mcp | | DELETE | /experimental/worktree | Stubbed | Experimental endpoints return empty stubs. | E2E: openapi-coverage | | GET | /experimental/worktree | Stubbed | Experimental endpoints return empty stubs. | E2E: openapi-coverage | | POST | /experimental/worktree | Stubbed | Experimental endpoints return empty stubs. | E2E: openapi-coverage | @@ -29,7 +29,7 @@ Authentication matches `/v1`: if a token is configured, requests must include `A | GET | /find | Stubbed | Returns empty results. | E2E: openapi-coverage | | GET | /find/file | Stubbed | Returns empty results. | E2E: openapi-coverage | | GET | /find/symbol | Stubbed | Returns empty results. | E2E: openapi-coverage | -| GET | /formatter | Stubbed | | E2E: openapi-coverage | +| GET | /formatter | Derived | Scans workspace files to report formatter availability. | E2E: openapi-coverage, formatter-lsp | | GET | /global/config | Stubbed | | E2E: openapi-coverage | | PATCH | /global/config | Stubbed | | E2E: openapi-coverage | | POST | /global/dispose | Stubbed | | E2E: openapi-coverage | @@ -37,15 +37,15 @@ Authentication matches `/v1`: if a token is configured, requests must include `A | GET | /global/health | Stubbed | | E2E: openapi-coverage | | POST | /instance/dispose | Stubbed | | E2E: openapi-coverage | | POST | /log | Stubbed | | E2E: openapi-coverage | -| GET | /lsp | Stubbed | | E2E: openapi-coverage | -| GET | /mcp | Stubbed | Returns disabled/needs_auth stubs. | E2E: openapi-coverage | -| POST | /mcp | Stubbed | Returns disabled/needs_auth stubs. | E2E: openapi-coverage | -| DELETE | /mcp/{name}/auth | Stubbed | Returns disabled/needs_auth stubs. | E2E: openapi-coverage | -| POST | /mcp/{name}/auth | Stubbed | Returns disabled/needs_auth stubs. | E2E: openapi-coverage | -| POST | /mcp/{name}/auth/authenticate | Stubbed | Returns disabled/needs_auth stubs. | E2E: openapi-coverage | -| POST | /mcp/{name}/auth/callback | Stubbed | Returns disabled/needs_auth stubs. | E2E: openapi-coverage | -| POST | /mcp/{name}/connect | Stubbed | Returns disabled/needs_auth stubs. | E2E: openapi-coverage | -| POST | /mcp/{name}/disconnect | Stubbed | Returns disabled/needs_auth stubs. | E2E: openapi-coverage | +| GET | /lsp | Derived | Reports LSP status per language based on workspace scan and PATH. | E2E: openapi-coverage, formatter-lsp | +| GET | /mcp | Stateful | Lists MCP registry status. | E2E: openapi-coverage, opencode-mcp | +| POST | /mcp | Stateful | Registers MCP servers and stores config. | E2E: openapi-coverage, opencode-mcp | +| DELETE | /mcp/{name}/auth | Stateful | Clears MCP auth credentials. | E2E: openapi-coverage, opencode-mcp | +| POST | /mcp/{name}/auth | Stateful | Starts MCP auth flow. | E2E: openapi-coverage, opencode-mcp | +| POST | /mcp/{name}/auth/authenticate | Stateful | Returns MCP auth status. | E2E: openapi-coverage, opencode-mcp | +| POST | /mcp/{name}/auth/callback | Stateful | Completes MCP auth and connects. | E2E: openapi-coverage, opencode-mcp | +| POST | /mcp/{name}/connect | Stateful | Connects MCP server and loads tools. | E2E: openapi-coverage, opencode-mcp | +| POST | /mcp/{name}/disconnect | Stateful | Disconnects MCP server and clears tools. | E2E: openapi-coverage, opencode-mcp | | GET | /path | Derived stub | | E2E: openapi-coverage | | GET | /permission | Stubbed | | E2E: openapi-coverage, permissions | | POST | /permission/{requestID}/reply | Stubbed | | E2E: openapi-coverage, permissions | @@ -105,4 +105,4 @@ Authentication matches `/v1`: if a token is configured, requests must include `A | POST | /tui/select-session | Stubbed | Returns true/empty control payloads. | E2E: openapi-coverage | | POST | /tui/show-toast | Stubbed | Returns true/empty control payloads. | E2E: openapi-coverage | | POST | /tui/submit-prompt | Stubbed | Returns true/empty control payloads. | E2E: openapi-coverage | -| GET | /vcs | Derived stub | | E2E: openapi-coverage | \ No newline at end of file +| GET | /vcs | Derived stub | | E2E: openapi-coverage | diff --git a/docs/session-transcript-schema.mdx b/docs/session-transcript-schema.mdx index a5a0158..fc39606 100644 --- a/docs/session-transcript-schema.mdx +++ b/docs/session-transcript-schema.mdx @@ -291,8 +291,9 @@ File reference with optional diff. | Field | Type | Description | |-------|------|-------------| | `path` | string | File path | -| `action` | string | `read`, `write`, `patch` | +| `action` | string | `read`, `write`, `patch`, `rename`, `delete` | | `diff` | string? | Unified diff (for patches) | +| `target_path` | string? | Destination path for renames | ```json { diff --git a/frontend/packages/inspector/src/components/chat/renderContentPart.tsx b/frontend/packages/inspector/src/components/chat/renderContentPart.tsx index ce61f9f..440de5c 100644 --- a/frontend/packages/inspector/src/components/chat/renderContentPart.tsx +++ b/frontend/packages/inspector/src/components/chat/renderContentPart.tsx @@ -44,11 +44,17 @@ const renderContentPart = (part: ContentPart, index: number) => { ); } case "file_ref": { - const { path, action, diff } = part as { path: string; action: string; diff?: string | null }; + const { path, action, diff, target_path } = part as { + path: string; + action: string; + diff?: string | null; + target_path?: string | null; + }; + const displayPath = target_path && action === "rename" ? `${path} -> ${target_path}` : path; return (
file - {action}
-
{path}
+
{displayPath}
{diff &&
{diff}
}
); diff --git a/sdks/typescript/src/generated/openapi.ts b/sdks/typescript/src/generated/openapi.ts index 52816ad..c882705 100644 --- a/sdks/typescript/src/generated/openapi.ts +++ b/sdks/typescript/src/generated/openapi.ts @@ -131,6 +131,7 @@ export interface components { action: components["schemas"]["FileAction"]; diff?: string | null; path: string; + target_path?: string | null; /** @enum {string} */ type: "file_ref"; }) | { @@ -183,7 +184,7 @@ export interface components { hasMore: boolean; }; /** @enum {string} */ - FileAction: "read" | "write" | "patch"; + FileAction: "read" | "write" | "patch" | "rename" | "delete"; HealthResponse: { status: string; }; diff --git a/server/packages/sandbox-agent/Cargo.toml b/server/packages/sandbox-agent/Cargo.toml index 5f45ad0..32d6047 100644 --- a/server/packages/sandbox-agent/Cargo.toml +++ b/server/packages/sandbox-agent/Cargo.toml @@ -23,6 +23,7 @@ axum.workspace = true clap.workspace = true futures.workspace = true reqwest.workspace = true +regress.workspace = true dirs.workspace = true time.workspace = true chrono.workspace = true @@ -36,6 +37,7 @@ tracing-logfmt.workspace = true tracing-subscriber.workspace = true include_dir.workspace = true base64.workspace = true +portable-pty = "0.8" tempfile = { workspace = true, optional = true } [target.'cfg(unix)'.dependencies] diff --git a/server/packages/sandbox-agent/src/formatter_lsp.rs b/server/packages/sandbox-agent/src/formatter_lsp.rs new file mode 100644 index 0000000..99997fb --- /dev/null +++ b/server/packages/sandbox-agent/src/formatter_lsp.rs @@ -0,0 +1,337 @@ +use std::collections::HashSet; +use std::env; +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; + +use serde::Serialize; + +const MAX_SCAN_FILES: usize = 10_000; +const MAX_SCAN_DEPTH: usize = 6; + +const IGNORE_DIRS: &[&str] = &[ + ".git", + ".idea", + ".sandbox-agent", + ".venv", + ".vscode", + "build", + "dist", + "node_modules", + "target", + "venv", +]; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FormatterStatus { + pub name: String, + pub extensions: Vec, + pub enabled: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspStatus { + pub id: String, + pub name: String, + pub root: String, + pub status: String, +} + +#[derive(Debug, Clone)] +pub struct FormatterService { + formatters: Vec, +} + +#[derive(Debug, Clone)] +pub struct LspRegistry { + servers: Vec, +} + +#[derive(Debug, Clone)] +struct FormatterDefinition { + name: &'static str, + extensions: &'static [&'static str], + config_files: &'static [&'static str], + binaries: &'static [&'static str], +} + +#[derive(Debug, Clone)] +struct LspDefinition { + id: &'static str, + name: &'static str, + extensions: &'static [&'static str], + binaries: &'static [&'static str], + #[allow(dead_code)] + capabilities: &'static [&'static str], +} + +impl FormatterService { + pub fn new() -> Self { + Self { + formatters: vec![ + FormatterDefinition { + name: "prettier", + extensions: &[ + ".js", ".jsx", ".ts", ".tsx", ".json", ".css", ".scss", ".md", ".mdx", + ".yaml", ".yml", ".html", + ], + config_files: &[ + ".prettierrc", + ".prettierrc.json", + ".prettierrc.yaml", + ".prettierrc.yml", + ".prettierrc.js", + ".prettierrc.cjs", + ".prettierrc.mjs", + "prettier.config.js", + "prettier.config.cjs", + "prettier.config.mjs", + ], + binaries: &["prettier"], + }, + FormatterDefinition { + name: "rustfmt", + extensions: &[".rs"], + config_files: &["rustfmt.toml"], + binaries: &["rustfmt"], + }, + FormatterDefinition { + name: "gofmt", + extensions: &[".go"], + config_files: &[], + binaries: &["gofmt"], + }, + FormatterDefinition { + name: "black", + extensions: &[".py"], + config_files: &["pyproject.toml", "black.toml", "setup.cfg"], + binaries: &["black"], + }, + FormatterDefinition { + name: "shfmt", + extensions: &[".sh", ".bash"], + config_files: &[], + binaries: &["shfmt"], + }, + FormatterDefinition { + name: "stylua", + extensions: &[".lua"], + config_files: &["stylua.toml"], + binaries: &["stylua"], + }, + ], + } + } + + pub fn status_for_directory(&self, directory: &str) -> Vec { + let root = resolve_root(directory); + let scan = scan_workspace(root); + let mut entries = Vec::new(); + + for formatter in &self.formatters { + let has_extension = formatter + .extensions + .iter() + .any(|ext| scan.extensions.contains(&ext.to_ascii_lowercase())); + let has_config = formatter + .config_files + .iter() + .any(|name| scan.file_names.contains(&name.to_ascii_lowercase())); + if !has_extension && !has_config { + continue; + } + let enabled = has_binary_in_workspace(root, formatter.binaries); + entries.push(FormatterStatus { + name: formatter.name.to_string(), + extensions: formatter + .extensions + .iter() + .map(|ext| ext.to_string()) + .collect(), + enabled, + }); + } + + entries + } +} + +impl LspRegistry { + pub fn new() -> Self { + Self { + servers: vec![ + LspDefinition { + id: "rust-analyzer", + name: "Rust Analyzer", + extensions: &[".rs"], + binaries: &["rust-analyzer"], + capabilities: &["completion", "diagnostics", "formatting"], + }, + LspDefinition { + id: "typescript-language-server", + name: "TypeScript Language Server", + extensions: &[".ts", ".tsx", ".js", ".jsx"], + binaries: &["typescript-language-server", "tsserver"], + capabilities: &["completion", "diagnostics", "formatting"], + }, + LspDefinition { + id: "pyright", + name: "Pyright", + extensions: &[".py"], + binaries: &["pyright-langserver", "pyright"], + capabilities: &["completion", "diagnostics"], + }, + LspDefinition { + id: "gopls", + name: "gopls", + extensions: &[".go"], + binaries: &["gopls"], + capabilities: &["completion", "diagnostics", "formatting"], + }, + ], + } + } + + pub fn status_for_directory(&self, directory: &str) -> Vec { + let root = resolve_root(directory); + let scan = scan_workspace(root); + let mut entries = Vec::new(); + + for server in &self.servers { + let has_extension = server + .extensions + .iter() + .any(|ext| scan.extensions.contains(&ext.to_ascii_lowercase())); + if !has_extension { + continue; + } + let status = if has_binary_in_workspace(root, server.binaries) { + "connected" + } else { + "error" + }; + entries.push(LspStatus { + id: server.id.to_string(), + name: server.name.to_string(), + root: root.to_string_lossy().to_string(), + status: status.to_string(), + }); + } + + entries + } +} + +#[derive(Default)] +struct WorkspaceScan { + extensions: HashSet, + file_names: HashSet, +} + +fn resolve_root(directory: &str) -> PathBuf { + let root = PathBuf::from(directory); + if root.is_dir() { + return root; + } + env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) +} + +fn scan_workspace(root: &Path) -> WorkspaceScan { + let mut scan = WorkspaceScan::default(); + let mut stack = Vec::new(); + let mut files_seen = 0usize; + stack.push((root.to_path_buf(), 0usize)); + + while let Some((dir, depth)) = stack.pop() { + if depth > MAX_SCAN_DEPTH { + continue; + } + let entries = match std::fs::read_dir(&dir) { + Ok(entries) => entries, + Err(_) => continue, + }; + for entry in entries.flatten() { + let path = entry.path(); + let file_type = match entry.file_type() { + Ok(file_type) => file_type, + Err(_) => continue, + }; + let name = entry.file_name(); + if file_type.is_dir() { + if should_skip_dir(&name) { + continue; + } + stack.push((path, depth + 1)); + } else if file_type.is_file() { + files_seen += 1; + if files_seen > MAX_SCAN_FILES { + return scan; + } + if let Some(extension) = path.extension().and_then(|ext| ext.to_str()) { + scan.extensions + .insert(format!(".{}", extension.to_ascii_lowercase())); + } + if let Some(name) = name.to_str() { + scan.file_names.insert(name.to_ascii_lowercase()); + } + } + } + } + + scan +} + +fn should_skip_dir(name: &OsStr) -> bool { + let Some(name) = name.to_str() else { + return false; + }; + let name = name.to_ascii_lowercase(); + IGNORE_DIRS.iter().any(|dir| dir == &name) +} + +fn has_binary_in_workspace(root: &Path, binaries: &[&str]) -> bool { + binaries + .iter() + .any(|binary| binary_exists_in_workspace(root, binary) || binary_exists_in_path(binary)) +} + +fn binary_exists_in_workspace(root: &Path, binary: &str) -> bool { + let bin_dir = root.join("node_modules").join(".bin"); + if !bin_dir.is_dir() { + return false; + } + path_has_binary(&bin_dir, binary) +} + +fn binary_exists_in_path(binary: &str) -> bool { + let Some(paths) = env::var_os("PATH") else { + return false; + }; + for path in env::split_paths(&paths) { + if path_has_binary(&path, binary) { + return true; + } + } + false +} + +fn path_has_binary(path: &Path, binary: &str) -> bool { + let candidate = path.join(binary); + if candidate.is_file() { + return true; + } + if cfg!(windows) { + if let Some(exts) = std::env::var_os("PATHEXT") { + for ext in std::env::split_paths(&exts) { + if let Some(ext_str) = ext.to_str() { + let candidate = path.join(format!("{}{}", binary, ext_str)); + if candidate.is_file() { + return true; + } + } + } + } + } + false +} diff --git a/server/packages/sandbox-agent/src/lib.rs b/server/packages/sandbox-agent/src/lib.rs index 8c11343..8853f49 100644 --- a/server/packages/sandbox-agent/src/lib.rs +++ b/server/packages/sandbox-agent/src/lib.rs @@ -1,9 +1,12 @@ //! Sandbox agent core utilities. mod agent_server_logs; +pub mod formatter_lsp; pub mod credentials; pub mod opencode_compat; +pub mod pty; pub mod router; +pub mod search; pub mod server_logs; pub mod telemetry; pub mod ui; diff --git a/server/packages/sandbox-agent/src/opencode_compat.rs b/server/packages/sandbox-agent/src/opencode_compat.rs index 55b7050..eb86615 100644 --- a/server/packages/sandbox-agent/src/opencode_compat.rs +++ b/server/packages/sandbox-agent/src/opencode_compat.rs @@ -2,28 +2,38 @@ //! //! These endpoints implement the full OpenCode OpenAPI surface. Most routes are //! stubbed responses with deterministic helpers for snapshot testing. A minimal -//! in-memory state tracks sessions/messages/ptys to keep behavior coherent. +//! in-memory state tracks sessions/messages to keep behavior coherent. -use std::collections::HashMap; +use std::collections::{HashMap, VecDeque}; use std::convert::Infallible; +use std::fs; +use std::path::Path as FsPath; +use std::path::PathBuf; use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::Arc; +use std::sync::{Arc, Mutex as StdMutex}; use std::str::FromStr; use axum::extract::{Path, Query, State}; +use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; use axum::http::{HeaderMap, StatusCode}; use axum::response::sse::{Event, KeepAlive}; use axum::response::{IntoResponse, Sse}; use axum::routing::{get, patch, post, put}; use axum::{Json, Router}; -use futures::stream; +use futures::{stream, SinkExt, StreamExt}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; +use tokio::process::Command; use tokio::sync::{broadcast, Mutex}; use tokio::time::interval; +use tokio_stream::wrappers::ReceiverStream; use utoipa::{IntoParams, OpenApi, ToSchema}; -use crate::router::{AppState, CreateSessionRequest, PermissionReply}; +use crate::pty::{PtyCreateOptions, PtyEvent, PtyIo, PtyRecord, PtySizeSpec, PtyUpdateOptions}; +use crate::router::{ + AppState, CreateSessionRequest, FileActionSnapshot, McpRegistryError, McpServerConfig, + PermissionReply, ToolCallSnapshot, ToolCallStatus, +}; use sandbox_agent_error::SandboxError; use sandbox_agent_agent_management::agents::AgentId; use sandbox_agent_universal_agent_schema::{ @@ -41,6 +51,19 @@ const OPENCODE_PROVIDER_ID: &str = "sandbox-agent"; const OPENCODE_PROVIDER_NAME: &str = "Sandbox Agent"; const OPENCODE_DEFAULT_MODEL_ID: &str = "mock"; const OPENCODE_DEFAULT_AGENT_MODE: &str = "build"; +const FIND_MAX_RESULTS: usize = 200; +const FIND_IGNORE_DIRS: &[&str] = &[ + ".git", + ".idea", + ".sandbox-agent", + ".venv", + ".vscode", + "build", + "dist", + "node_modules", + "target", + "venv", +]; #[derive(Clone, Debug)] struct OpenCodeCompatConfig { @@ -77,9 +100,22 @@ impl OpenCodeCompatConfig { .map(|d| d.as_millis() as i64) .unwrap_or(0) } + + fn home_dir(&self) -> String { + self.fixed_home + .clone() + .or_else(|| std::env::var("HOME").ok()) + .unwrap_or_else(|| "/".to_string()) + } + + fn state_dir(&self) -> String { + self.fixed_state + .clone() + .unwrap_or_else(|| format!("{}/.local/state/opencode", self.home_dir())) + } } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Serialize, Deserialize)] struct OpenCodeSessionRecord { id: String, slug: String, @@ -91,6 +127,8 @@ struct OpenCodeSessionRecord { created_at: i64, updated_at: i64, share_url: Option, + #[serde(default)] + status: OpenCodeSessionStatus, } impl OpenCodeSessionRecord { @@ -115,40 +153,46 @@ impl OpenCodeSessionRecord { if let Some(url) = &self.share_url { map.insert("share".to_string(), json!({"url": url})); } + map.insert( + "status".to_string(), + json!({"type": self.status.status, "updated": self.status.updated_at}), + ); Value::Object(map) } } +#[derive(Clone, Debug, Serialize, Deserialize, Default)] +struct OpenCodeSessionStatus { + status: String, + updated_at: i64, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Default)] +struct OpenCodePersistedState { + #[serde(default)] + default_project_id: String, + #[serde(default)] + next_session_id: u64, + #[serde(default)] + sessions: HashMap, +} + +impl OpenCodePersistedState { + fn empty(default_project_id: String) -> Self { + Self { + default_project_id, + next_session_id: 1, + sessions: HashMap::new(), + } + } +} + #[derive(Clone, Debug)] struct OpenCodeMessageRecord { info: Value, parts: Vec, } -#[derive(Clone, Debug)] -struct OpenCodePtyRecord { - id: String, - title: String, - command: String, - args: Vec, - cwd: String, - status: String, - pid: i64, -} - -impl OpenCodePtyRecord { - fn to_value(&self) -> Value { - json!({ - "id": self.id, - "title": self.title, - "command": self.command, - "args": self.args, - "cwd": self.cwd, - "status": self.status, - "pid": self.pid, - }) - } -} #[derive(Clone, Debug)] struct OpenCodePermissionRecord { @@ -198,6 +242,12 @@ impl OpenCodeQuestionRecord { } } +#[derive(Clone, Debug)] +struct OpenCodeEventRecord { + sequence: u64, + payload: Value, +} + #[derive(Default, Clone)] struct OpenCodeSessionRuntime { last_user_message_id: Option, @@ -217,40 +267,84 @@ struct OpenCodeSessionRuntime { pub struct OpenCodeState { config: OpenCodeCompatConfig, default_project_id: String, + session_store_path: PathBuf, sessions: Mutex>, messages: Mutex>>, - ptys: Mutex>, permissions: Mutex>, questions: Mutex>, session_runtime: Mutex>, session_streams: Mutex>, - event_broadcaster: broadcast::Sender, + event_log: StdMutex>, + event_sequence: AtomicU64, + event_broadcaster: broadcast::Sender, } impl OpenCodeState { pub fn new() -> Self { let (event_broadcaster, _) = broadcast::channel(256); - let project_id = format!("proj_{}", PROJECT_COUNTER.fetch_add(1, Ordering::Relaxed)); + let config = OpenCodeCompatConfig::from_env(); + let state_dir = config.state_dir(); + let session_store_path = PathBuf::from(state_dir).join("sessions.json"); + let mut persisted = load_persisted_state(&session_store_path).unwrap_or_else(|| { + let project_id = format!("proj_{}", PROJECT_COUNTER.fetch_add(1, Ordering::Relaxed)); + OpenCodePersistedState::empty(project_id) + }); + if persisted.default_project_id.is_empty() { + persisted.default_project_id = + format!("proj_{}", PROJECT_COUNTER.fetch_add(1, Ordering::Relaxed)); + } + for session in persisted.sessions.values_mut() { + if session.status.status.is_empty() { + session.status.status = "idle".to_string(); + session.status.updated_at = session.updated_at; + } + } + let derived_next = next_session_id_from(&persisted.sessions); + if persisted.next_session_id < derived_next { + persisted.next_session_id = derived_next; + } + SESSION_COUNTER.store(persisted.next_session_id, Ordering::Relaxed); Self { - config: OpenCodeCompatConfig::from_env(), - default_project_id: project_id, - sessions: Mutex::new(HashMap::new()), + config, + default_project_id: persisted.default_project_id, + session_store_path, + sessions: Mutex::new(persisted.sessions), messages: Mutex::new(HashMap::new()), - ptys: Mutex::new(HashMap::new()), permissions: Mutex::new(HashMap::new()), questions: Mutex::new(HashMap::new()), session_runtime: Mutex::new(HashMap::new()), session_streams: Mutex::new(HashMap::new()), + event_log: StdMutex::new(Vec::new()), + event_sequence: AtomicU64::new(0), event_broadcaster, } } - pub fn subscribe(&self) -> broadcast::Receiver { + pub fn subscribe(&self) -> broadcast::Receiver { self.event_broadcaster.subscribe() } pub fn emit_event(&self, event: Value) { - let _ = self.event_broadcaster.send(event); + let sequence = self.event_sequence.fetch_add(1, Ordering::Relaxed) + 1; + let record = OpenCodeEventRecord { + sequence, + payload: event, + }; + if let Ok(mut events) = self.event_log.lock() { + events.push(record.clone()); + } + let _ = self.event_broadcaster.send(record); + } + + pub fn events_since(&self, offset: u64) -> Vec { + let Ok(events) = self.event_log.lock() else { + return Vec::new(); + }; + events + .iter() + .filter(|record| record.sequence > offset) + .cloned() + .collect() } fn now_ms(&self) -> i64 { @@ -289,18 +383,87 @@ impl OpenCodeState { } fn home_dir(&self) -> String { - self.config - .fixed_home - .clone() - .or_else(|| std::env::var("HOME").ok()) - .unwrap_or_else(|| "/".to_string()) + self.config.home_dir() } fn state_dir(&self) -> String { - self.config - .fixed_state - .clone() - .unwrap_or_else(|| format!("{}/.local/state/opencode", self.home_dir())) + self.config.state_dir() + } + + async fn persist_sessions(&self) { + let sessions = self.sessions.lock().await; + let persisted = OpenCodePersistedState { + default_project_id: self.default_project_id.clone(), + next_session_id: SESSION_COUNTER.load(Ordering::Relaxed), + sessions: sessions.clone(), + }; + drop(sessions); + let path = self.session_store_path.clone(); + let payload = match serde_json::to_vec_pretty(&persisted) { + Ok(value) => value, + Err(err) => { + tracing::warn!( + target = "sandbox_agent::opencode", + ?err, + "failed to serialize session store" + ); + return; + } + }; + let result = tokio::task::spawn_blocking(move || { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&path, payload) + }) + .await; + match result { + Ok(Ok(())) => {} + Ok(Err(err)) => { + tracing::warn!( + target = "sandbox_agent::opencode", + ?err, + "failed to persist session store" + ); + } + Err(err) => { + tracing::warn!( + target = "sandbox_agent::opencode", + ?err, + "failed to persist session store" + ); + } + } + } + + async fn mutate_session( + &self, + session_id: &str, + update: impl FnOnce(&mut OpenCodeSessionRecord), + ) -> Option { + let mut sessions = self.sessions.lock().await; + let session = sessions.get_mut(session_id)?; + update(session); + Some(session.clone()) + } + + async fn update_session_status( + &self, + session_id: &str, + status: &str, + ) -> Option { + let now = self.now_ms(); + let updated = self + .mutate_session(session_id, |session| { + session.status.status = status.to_string(); + session.status.updated_at = now; + session.updated_at = now; + }) + .await; + if updated.is_some() { + self.persist_sessions().await; + } + updated } async fn ensure_session(&self, session_id: &str, directory: String) -> Value { @@ -321,12 +484,17 @@ impl OpenCodeState { created_at: now, updated_at: now, share_url: None, + status: OpenCodeSessionStatus { + status: "idle".to_string(), + updated_at: now, + }, }; let value = record.to_value(); sessions.insert(session_id.to_string(), record); drop(sessions); self.emit_event(session_event("session.created", &value)); + self.persist_sessions().await; value } @@ -366,10 +534,12 @@ pub struct OpenCodeAppState { impl OpenCodeAppState { pub fn new(inner: Arc) -> Arc { - Arc::new(Self { + let state = Arc::new(Self { inner, opencode: Arc::new(OpenCodeState::new()), - }) + }); + spawn_pty_event_forwarder(state.clone()); + state } } @@ -466,6 +636,12 @@ struct DirectoryQuery { directory: Option, } +#[derive(Debug, Deserialize, IntoParams)] +struct EventStreamQuery { + directory: Option, + offset: Option, +} + #[derive(Debug, Deserialize, IntoParams)] struct ToolQuery { directory: Option, @@ -473,22 +649,43 @@ struct ToolQuery { model: Option, } +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +struct OpenCodeMcpRegisterRequest { + name: String, + config: McpServerConfig, +} + +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +struct OpenCodeMcpAuthCallbackRequest { + code: String, +} + #[derive(Debug, Deserialize, IntoParams)] struct FindTextQuery { directory: Option, pattern: Option, + #[serde(rename = "caseSensitive")] + case_sensitive: Option, + limit: Option, } #[derive(Debug, Deserialize, IntoParams)] struct FindFilesQuery { directory: Option, query: Option, + dirs: Option, + #[serde(rename = "type")] + entry_type: Option, + limit: Option, } #[derive(Debug, Deserialize, IntoParams)] struct FindSymbolsQuery { directory: Option, query: Option, + limit: Option, } #[derive(Debug, Deserialize, IntoParams)] @@ -568,6 +765,21 @@ struct PtyCreateRequest { args: Option>, cwd: Option, title: Option, + env: Option>, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +struct PtySizeRequest { + rows: u16, + cols: u16, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +struct PtyUpdateRequest { + title: Option, + size: Option, } fn next_id(prefix: &str, counter: &AtomicU64) -> String { @@ -575,6 +787,44 @@ fn next_id(prefix: &str, counter: &AtomicU64) -> String { format!("{}{}", prefix, id) } +fn next_session_id_from(sessions: &HashMap) -> u64 { + let mut max_id = 0; + for session_id in sessions.keys() { + if let Some(raw) = session_id.strip_prefix("ses_") { + if let Ok(value) = raw.parse::() { + if value > max_id { + max_id = value; + } + } + } + } + if max_id == 0 { + 1 + } else { + max_id + 1 + } +} + +fn bump_version(version: &str) -> String { + let parsed = version.parse::().unwrap_or(0); + (parsed + 1).to_string() +} + +fn load_persisted_state(path: &FsPath) -> Option { + let contents = fs::read_to_string(path).ok()?; + match serde_json::from_str::(&contents) { + Ok(state) => Some(state), + Err(err) => { + tracing::warn!( + target = "sandbox_agent::opencode", + ?err, + "failed to parse session store" + ); + None + } + } +} + fn available_agent_ids() -> Vec { vec![ AgentId::Claude, @@ -769,6 +1019,14 @@ fn sandbox_error_response(err: SandboxError) -> (StatusCode, Json) { } } +fn mcp_error_response(err: McpRegistryError) -> (StatusCode, Json) { + match err { + McpRegistryError::NotFound => not_found("MCP server not found"), + McpRegistryError::Invalid(message) => bad_request(&message), + McpRegistryError::Transport(message) => internal_error(&message), + } +} + fn parse_permission_reply_value(value: Option<&str>) -> Result { let value = value.unwrap_or("once").to_ascii_lowercase(); match value.as_str() { @@ -783,6 +1041,40 @@ fn bool_ok(value: bool) -> (StatusCode, Json) { (StatusCode::OK, Json(json!(value))) } +fn pty_to_value(pty: &PtyRecord) -> Value { + json!({ + "id": pty.id, + "title": pty.title, + "command": pty.command, + "args": pty.args, + "cwd": pty.cwd, + "status": pty.status.as_str(), + "pid": pty.pid, + }) +} + +fn spawn_pty_event_forwarder(state: Arc) { + let mut receiver = state + .inner + .session_manager() + .pty_manager() + .subscribe(); + tokio::spawn(async move { + loop { + match receiver.recv().await { + Ok(PtyEvent::Exited { id, exit_code }) => { + state.opencode.emit_event(json!({ + "type": "pty.exited", + "properties": {"id": id, "exitCode": exit_code} + })); + } + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => break, + } + } + }); +} + fn build_user_message( session_id: &str, message_id: &str, @@ -1233,7 +1525,7 @@ struct ToolContentInfo { tool_name: Option, arguments: Option, output: Option, - file_refs: Vec<(String, FileAction, Option)>, + file_refs: Vec<(String, FileAction, Option, Option)>, } fn extract_tool_content(parts: &[ContentPart]) -> ToolContentInfo { @@ -1253,9 +1545,18 @@ fn extract_tool_content(parts: &[ContentPart]) -> ToolContentInfo { info.call_id = Some(call_id.clone()); info.output = Some(output.clone()); } - ContentPart::FileRef { path, action, diff } => { - info.file_refs - .push((path.clone(), action.clone(), diff.clone())); + ContentPart::FileRef { + path, + action, + diff, + target_path, + } => { + info.file_refs.push(( + path.clone(), + action.clone(), + diff.clone(), + target_path.clone(), + )); } _ => {} } @@ -1275,6 +1576,105 @@ fn tool_input_from_arguments(arguments: Option<&str>) -> Value { json!({ "arguments": arguments }) } +async fn tool_call_snapshot( + state: &OpenCodeAppState, + session_id: &str, + call_id: &str, +) -> Option { + state + .inner + .session_manager() + .tool_call_snapshot(session_id, call_id) + .await +} + +async fn file_actions_for_event( + state: &OpenCodeAppState, + session_id: &str, + sequence: u64, +) -> Vec { + state + .inner + .session_manager() + .file_actions_for_event(session_id, sequence) + .await +} + +fn tool_state_from_snapshot( + snapshot: Option<&ToolCallSnapshot>, + fallback_name: &str, + fallback_arguments: Option<&str>, + fallback_output: Option<&str>, + attachments: Vec, + now: i64, +) -> (String, Value) { + let tool_name = snapshot + .and_then(|state| state.name.clone()) + .unwrap_or_else(|| fallback_name.to_string()); + let arguments = snapshot + .and_then(|state| state.arguments.clone()) + .or_else(|| fallback_arguments.map(|value| value.to_string())); + let raw_args = arguments.clone().unwrap_or_default(); + let input_value = tool_input_from_arguments(arguments.as_deref()); + let output = snapshot + .and_then(|state| state.output.clone()) + .or_else(|| fallback_output.map(|value| value.to_string())); + let status = snapshot.map(|state| &state.status); + + let state_value = match status { + Some(ToolCallStatus::Result) => json!({ + "status": "completed", + "input": input_value, + "output": output.unwrap_or_default(), + "title": "Tool result", + "metadata": {}, + "time": {"start": now, "end": now}, + "attachments": attachments, + }), + Some(ToolCallStatus::Failed) => json!({ + "status": "error", + "input": input_value, + "error": output.unwrap_or_else(|| "Tool failed".to_string()), + "metadata": {}, + "time": {"start": now, "end": now}, + }), + Some(ToolCallStatus::Completed) + | Some(ToolCallStatus::Running) + | Some(ToolCallStatus::Delta) => json!({ + "status": "running", + "input": input_value, + "time": {"start": now}, + }), + Some(ToolCallStatus::Started) | None => json!({ + "status": "pending", + "input": input_value, + "raw": raw_args, + }), + }; + + (tool_name, state_value) +} + +fn file_action_applied( + file_actions: &[FileActionSnapshot], + path: &str, + action: &FileAction, + target_path: Option<&str>, + diff: Option<&str>, +) -> bool { + file_actions.iter().any(|record| { + let diff_matches = match diff { + Some(value) => record.diff.as_deref() == Some(value), + None => true, + }; + record.action == *action + && record.path == path + && record.target_path.as_deref() == target_path + && diff_matches + && record.applied + }) +} + fn patterns_from_metadata(metadata: &Option) -> Vec { let mut patterns = Vec::new(); let Some(metadata) = metadata else { @@ -1334,6 +1734,10 @@ async fn apply_universal_event(state: Arc, event: UniversalEve "type": "session.idle", "properties": {"sessionID": event.session_id} })); + let _ = state + .opencode + .update_session_status(&event.session_id, "idle") + .await; } UniversalEventType::PermissionRequested | UniversalEventType::PermissionResolved => { if let UniversalEventData::Permission(permission) = &event.data { @@ -1347,6 +1751,13 @@ async fn apply_universal_event(state: Arc, event: UniversalEve } UniversalEventType::Error => { if let UniversalEventData::Error(error) = &event.data { + state.opencode.emit_event(json!({ + "type": "session.status", + "properties": { + "sessionID": event.session_id, + "status": {"type": "error"} + } + })); state.opencode.emit_event(json!({ "type": "session.error", "properties": { @@ -1358,6 +1769,10 @@ async fn apply_universal_event(state: Arc, event: UniversalEve } } })); + let _ = state + .opencode + .update_session_status(&event.session_id, "error") + .await; } } _ => {} @@ -1578,6 +1993,20 @@ async fn apply_item_event( state .opencode .emit_event(message_event("message.updated", &info)); + if event.event_type == UniversalEventType::ItemCompleted { + state.opencode.emit_event(json!({ + "type": "session.status", + "properties": {"sessionID": session_id, "status": {"type": "idle"}} + })); + state.opencode.emit_event(json!({ + "type": "session.idle", + "properties": {"sessionID": session_id} + })); + let _ = state + .opencode + .update_session_status(&session_id, "idle") + .await; + } let mut runtime = state .opencode @@ -1613,6 +2042,8 @@ async fn apply_item_event( .await; } + let file_actions = file_actions_for_event(&state, &session_id, event.sequence).await; + for part in item.content.iter() { match part { ContentPart::Reasoning { text, .. } => { @@ -1635,13 +2066,23 @@ async fn apply_item_event( .entry(call_id.clone()) .or_insert_with(|| next_id("part_", &PART_COUNTER)) .clone(); - let state_value = json!({ - "status": "pending", - "input": {"arguments": arguments}, - "raw": arguments, - }); - let tool_part = - build_tool_part(&session_id, &message_id, &part_id, call_id, name, state_value); + let snapshot = tool_call_snapshot(&state, &session_id, call_id).await; + let (tool_name, state_value) = tool_state_from_snapshot( + snapshot.as_ref(), + name, + Some(arguments), + None, + Vec::new(), + now, + ); + let tool_part = build_tool_part( + &session_id, + &message_id, + &part_id, + call_id, + &tool_name, + state_value, + ); upsert_message_part(&state.opencode, &session_id, &message_id, tool_part.clone()) .await; state @@ -1665,21 +2106,21 @@ async fn apply_item_event( .entry(call_id.clone()) .or_insert_with(|| next_id("part_", &PART_COUNTER)) .clone(); - let state_value = json!({ - "status": "completed", - "input": {}, - "output": output, - "title": "Tool result", - "metadata": {}, - "time": {"start": now, "end": now}, - "attachments": [], - }); + let snapshot = tool_call_snapshot(&state, &session_id, call_id).await; + let (tool_name, state_value) = tool_state_from_snapshot( + snapshot.as_ref(), + "tool", + None, + Some(output), + Vec::new(), + now, + ); let tool_part = build_tool_part( &session_id, &message_id, &part_id, call_id, - "tool", + &tool_name, state_value, ); upsert_message_part(&state.opencode, &session_id, &message_id, tool_part.clone()) @@ -1699,19 +2140,39 @@ async fn apply_item_event( }) .await; } - ContentPart::FileRef { path, action, diff } => { + ContentPart::FileRef { + path, + action, + diff, + target_path, + } => { let mime = match action { FileAction::Patch => "text/x-diff", _ => "text/plain", }; - let part = - build_file_part_from_path(&session_id, &message_id, path, mime, diff.as_deref()); + let display_path = target_path.as_deref().unwrap_or(path); + let part = build_file_part_from_path( + &session_id, + &message_id, + display_path, + mime, + diff.as_deref(), + ); upsert_message_part(&state.opencode, &session_id, &message_id, part.clone()).await; state .opencode .emit_event(part_event("message.part.updated", &part)); - if matches!(action, FileAction::Write | FileAction::Patch) { - emit_file_edited(&state.opencode, path); + if matches!( + action, + FileAction::Write | FileAction::Patch | FileAction::Rename | FileAction::Delete + ) && file_action_applied( + &file_actions, + path, + action, + target_path.as_deref(), + diff.as_deref(), + ) { + emit_file_edited(&state.opencode, display_path); } } ContentPart::Image { path, mime } => { @@ -1845,22 +2306,38 @@ async fn apply_tool_item_event( .opencode .emit_event(message_event("message.updated", &info)); + let file_actions = file_actions_for_event(&state, &session_id, event.sequence).await; let mut attachments = Vec::new(); if item.kind == ItemKind::ToolResult && event.event_type == UniversalEventType::ItemCompleted { - for (path, action, diff) in tool_info.file_refs.iter() { + for (path, action, diff, target_path) in tool_info.file_refs.iter() { let mime = match action { FileAction::Patch => "text/x-diff", _ => "text/plain", }; - let part = - build_file_part_from_path(&session_id, &message_id, path, mime, diff.as_deref()); + let display_path = target_path.as_deref().unwrap_or(path); + let part = build_file_part_from_path( + &session_id, + &message_id, + display_path, + mime, + diff.as_deref(), + ); upsert_message_part(&state.opencode, &session_id, &message_id, part.clone()).await; state .opencode .emit_event(part_event("message.part.updated", &part)); attachments.push(part.clone()); - if matches!(action, FileAction::Write | FileAction::Patch) { - emit_file_edited(&state.opencode, path); + if matches!( + action, + FileAction::Write | FileAction::Patch | FileAction::Rename | FileAction::Delete + ) && file_action_applied( + &file_actions, + path, + action, + target_path.as_deref(), + diff.as_deref(), + ) { + emit_file_edited(&state.opencode, display_path); } } } @@ -1870,68 +2347,22 @@ async fn apply_tool_item_event( .get(&call_id) .cloned() .unwrap_or_else(|| next_id("part_", &PART_COUNTER)); - let tool_name = tool_info - .tool_name - .clone() - .unwrap_or_else(|| "tool".to_string()); - let input_value = tool_input_from_arguments(tool_info.arguments.as_deref()); - let raw_args = tool_info.arguments.clone().unwrap_or_default(); - let output_value = tool_info + let snapshot = tool_call_snapshot(&state, &session_id, &call_id).await; + let output_fallback = tool_info .output .clone() .or_else(|| extract_text_from_content(&item.content)); - - let state_value = match event.event_type { - UniversalEventType::ItemStarted => { - if item.kind == ItemKind::ToolResult { - json!({ - "status": "running", - "input": input_value, - "time": {"start": now} - }) - } else { - json!({ - "status": "pending", - "input": input_value, - "raw": raw_args, - }) - } - } - UniversalEventType::ItemCompleted => { - if item.kind == ItemKind::ToolResult { - if matches!(item.status, ItemStatus::Failed) { - json!({ - "status": "error", - "input": input_value, - "error": output_value.unwrap_or_else(|| "Tool failed".to_string()), - "metadata": {}, - "time": {"start": now, "end": now}, - }) - } else { - json!({ - "status": "completed", - "input": input_value, - "output": output_value.unwrap_or_default(), - "title": "Tool result", - "metadata": {}, - "time": {"start": now, "end": now}, - "attachments": attachments, - }) - } - } else { - json!({ - "status": "running", - "input": input_value, - "time": {"start": now}, - }) - } - } - _ => json!({ - "status": "pending", - "input": input_value, - "raw": raw_args, - }), - }; + let (tool_name, state_value) = tool_state_from_snapshot( + snapshot.as_ref(), + tool_info + .tool_name + .as_deref() + .unwrap_or("tool"), + tool_info.arguments.as_deref(), + output_fallback.as_deref(), + attachments, + now, + ); let tool_part = build_tool_part( &session_id, @@ -2297,9 +2728,20 @@ async fn oc_config_providers() -> impl IntoResponse { async fn oc_event_subscribe( State(state): State>, headers: HeaderMap, - Query(query): Query, + Query(query): Query, ) -> Sse>> { let receiver = state.opencode.subscribe(); + let offset = query + .offset + .or_else(|| { + headers + .get("last-event-id") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + }) + .unwrap_or(0); + let mut pending_events: VecDeque = + state.opencode.events_since(offset).into(); let directory = state.opencode.directory_for(&headers, query.directory.as_ref()); let branch = state.opencode.branch_name(); state.opencode.emit_event(json!({ @@ -2318,33 +2760,59 @@ async fn oc_event_subscribe( "type": "server.heartbeat", "properties": {} }); - let stream = stream::unfold((receiver, interval(std::time::Duration::from_secs(30))), move |(mut rx, mut ticker)| { - let heartbeat = heartbeat_payload.clone(); - async move { - tokio::select! { - _ = ticker.tick() => { - let sse_event = Event::default() - .json_data(&heartbeat) - .unwrap_or_else(|_| Event::default().data("{}")); - Some((Ok(sse_event), (rx, ticker))) - } - event = rx.recv() => { - match event { - Ok(event) => { + let opencode = state.opencode.clone(); + let stream = stream::unfold( + ( + receiver, + pending_events, + offset, + interval(std::time::Duration::from_secs(30)), + ), + move |(mut rx, mut pending, mut last_sequence, mut ticker)| { + let heartbeat = heartbeat_payload.clone(); + let opencode = opencode.clone(); + async move { + loop { + if let Some(record) = pending.pop_front() { + last_sequence = record.sequence; + let sse_event = Event::default() + .id(record.sequence.to_string()) + .json_data(&record.payload) + .unwrap_or_else(|_| Event::default().data("{}")); + return Some((Ok(sse_event), (rx, pending, last_sequence, ticker))); + } + tokio::select! { + _ = ticker.tick() => { let sse_event = Event::default() - .json_data(&event) + .json_data(&heartbeat) .unwrap_or_else(|_| Event::default().data("{}")); - Some((Ok(sse_event), (rx, ticker))) + return Some((Ok(sse_event), (rx, pending, last_sequence, ticker))); } - Err(broadcast::error::RecvError::Lagged(_)) => { - Some((Ok(Event::default().comment("lagged")), (rx, ticker))) + event = rx.recv() => { + match event { + Ok(record) => { + if record.sequence <= last_sequence { + continue; + } + last_sequence = record.sequence; + let sse_event = Event::default() + .id(record.sequence.to_string()) + .json_data(&record.payload) + .unwrap_or_else(|_| Event::default().data("{}")); + return Some((Ok(sse_event), (rx, pending, last_sequence, ticker))); + } + Err(broadcast::error::RecvError::Lagged(_)) => { + pending = opencode.events_since(last_sequence).into(); + continue; + } + Err(broadcast::error::RecvError::Closed) => return None, + } } - Err(broadcast::error::RecvError::Closed) => None, } } } - } - }); + }, + ); Sse::new(stream).keep_alive(KeepAlive::new().interval(std::time::Duration::from_secs(15))) } @@ -2358,9 +2826,20 @@ async fn oc_event_subscribe( async fn oc_global_event( State(state): State>, headers: HeaderMap, - Query(query): Query, + Query(query): Query, ) -> Sse>> { let receiver = state.opencode.subscribe(); + let offset = query + .offset + .or_else(|| { + headers + .get("last-event-id") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + }) + .unwrap_or(0); + let mut pending_events: VecDeque = + state.opencode.events_since(offset).into(); let directory = state.opencode.directory_for(&headers, query.directory.as_ref()); let branch = state.opencode.branch_name(); state.opencode.emit_event(json!({ @@ -2381,35 +2860,62 @@ async fn oc_global_event( "properties": {} } }); - let stream = stream::unfold((receiver, interval(std::time::Duration::from_secs(30))), move |(mut rx, mut ticker)| { - let directory = directory.clone(); - let heartbeat = heartbeat_payload.clone(); - async move { - tokio::select! { - _ = ticker.tick() => { - let sse_event = Event::default() - .json_data(&heartbeat) - .unwrap_or_else(|_| Event::default().data("{}")); - Some((Ok(sse_event), (rx, ticker))) - } - event = rx.recv() => { - match event { - Ok(event) => { - let payload = json!({"directory": directory, "payload": event}); + let opencode = state.opencode.clone(); + let stream = stream::unfold( + ( + receiver, + pending_events, + offset, + interval(std::time::Duration::from_secs(30)), + ), + move |(mut rx, mut pending, mut last_sequence, mut ticker)| { + let directory = directory.clone(); + let heartbeat = heartbeat_payload.clone(); + let opencode = opencode.clone(); + async move { + loop { + if let Some(record) = pending.pop_front() { + last_sequence = record.sequence; + let payload = json!({"directory": directory, "payload": record.payload}); + let sse_event = Event::default() + .id(record.sequence.to_string()) + .json_data(&payload) + .unwrap_or_else(|_| Event::default().data("{}")); + return Some((Ok(sse_event), (rx, pending, last_sequence, ticker))); + } + tokio::select! { + _ = ticker.tick() => { let sse_event = Event::default() - .json_data(&payload) + .json_data(&heartbeat) .unwrap_or_else(|_| Event::default().data("{}")); - Some((Ok(sse_event), (rx, ticker))) + return Some((Ok(sse_event), (rx, pending, last_sequence, ticker))); } - Err(broadcast::error::RecvError::Lagged(_)) => { - Some((Ok(Event::default().comment("lagged")), (rx, ticker))) + event = rx.recv() => { + match event { + Ok(record) => { + if record.sequence <= last_sequence { + continue; + } + last_sequence = record.sequence; + let payload = json!({"directory": directory, "payload": record.payload}); + let sse_event = Event::default() + .id(record.sequence.to_string()) + .json_data(&payload) + .unwrap_or_else(|_| Event::default().data("{}")); + return Some((Ok(sse_event), (rx, pending, last_sequence, ticker))); + } + Err(broadcast::error::RecvError::Lagged(_)) => { + pending = opencode.events_since(last_sequence).into(); + continue; + } + Err(broadcast::error::RecvError::Closed) => return None, + } } - Err(broadcast::error::RecvError::Closed) => None, } } } - } - }); + }, + ); Sse::new(stream).keep_alive(KeepAlive::new().interval(std::time::Duration::from_secs(15))) } @@ -2487,8 +2993,14 @@ async fn oc_log() -> impl IntoResponse { responses((status = 200)), tag = "opencode" )] -async fn oc_lsp_status() -> impl IntoResponse { - (StatusCode::OK, Json(json!([]))) +async fn oc_lsp_status( + State(state): State>, + headers: HeaderMap, + Query(query): Query, +) -> impl IntoResponse { + let directory = state.opencode.directory_for(&headers, query.directory.as_ref()); + let status = state.inner.session_manager().lsp_status(&directory); + (StatusCode::OK, Json(status)) } #[utoipa::path( @@ -2497,8 +3009,14 @@ async fn oc_lsp_status() -> impl IntoResponse { responses((status = 200)), tag = "opencode" )] -async fn oc_formatter_status() -> impl IntoResponse { - (StatusCode::OK, Json(json!([]))) +async fn oc_formatter_status( + State(state): State>, + headers: HeaderMap, + Query(query): Query, +) -> impl IntoResponse { + let directory = state.opencode.directory_for(&headers, query.directory.as_ref()); + let status = state.inner.session_manager().formatter_status(&directory); + (StatusCode::OK, Json(status)) } #[utoipa::path( @@ -2631,6 +3149,10 @@ async fn oc_session_create( created_at: now, updated_at: now, share_url: None, + status: OpenCodeSessionStatus { + status: "idle".to_string(), + updated_at: now, + }, }; let session_value = record.to_value(); @@ -2642,6 +3164,7 @@ async fn oc_session_create( state .opencode .emit_event(session_event("session.created", &session_value)); + state.opencode.persist_sessions().await; (StatusCode::OK, Json(session_value)) } @@ -2654,8 +3177,15 @@ async fn oc_session_create( )] async fn oc_session_list(State(state): State>) -> impl IntoResponse { let sessions = state.opencode.sessions.lock().await; - let values: Vec = sessions.values().map(|s| s.to_value()).collect(); - (StatusCode::OK, Json(json!(values))) + let mut values: Vec = sessions.values().cloned().collect(); + drop(sessions); + values.sort_by(|a, b| { + a.created_at + .cmp(&b.created_at) + .then_with(|| a.id.cmp(&b.id)) + }); + let response: Vec = values.into_iter().map(|s| s.to_value()).collect(); + (StatusCode::OK, Json(json!(response))) } #[utoipa::path( @@ -2691,16 +3221,22 @@ async fn oc_session_update( Path(session_id): Path, Json(body): Json, ) -> impl IntoResponse { - let mut sessions = state.opencode.sessions.lock().await; - if let Some(session) = sessions.get_mut(&session_id) { - if let Some(title) = body.title { - session.title = title; - session.updated_at = state.opencode.now_ms(); - } + let now = state.opencode.now_ms(); + let updated = state + .opencode + .mutate_session(&session_id, |session| { + if let Some(title) = body.title { + session.title = title; + session.updated_at = now; + } + }) + .await; + if let Some(session) = updated { let value = session.to_value(); state .opencode .emit_event(session_event("session.updated", &value)); + state.opencode.persist_sessions().await; return (StatusCode::OK, Json(value)).into_response(); } not_found("Session not found").into_response() @@ -2719,9 +3255,20 @@ async fn oc_session_delete( ) -> impl IntoResponse { let mut sessions = state.opencode.sessions.lock().await; if let Some(session) = sessions.remove(&session_id) { + drop(sessions); + let mut messages = state.opencode.messages.lock().await; + messages.remove(&session_id); + drop(messages); + let mut runtimes = state.opencode.session_runtime.lock().await; + runtimes.remove(&session_id); + drop(runtimes); + let mut streams = state.opencode.session_streams.lock().await; + streams.remove(&session_id); + drop(streams); state .opencode .emit_event(session_event("session.deleted", &session.to_value())); + state.opencode.persist_sessions().await; return bool_ok(true).into_response(); } not_found("Session not found").into_response() @@ -2736,8 +3283,14 @@ async fn oc_session_delete( async fn oc_session_status(State(state): State>) -> impl IntoResponse { let sessions = state.opencode.sessions.lock().await; let mut status_map = serde_json::Map::new(); - for id in sessions.keys() { - status_map.insert(id.clone(), json!({"type": "idle"})); + for (id, session) in sessions.iter() { + status_map.insert( + id.clone(), + json!({ + "type": session.status.status, + "updated": session.status.updated_at, + }), + ); } (StatusCode::OK, Json(Value::Object(status_map))) } @@ -2750,10 +3303,25 @@ async fn oc_session_status(State(state): State>) -> impl I tag = "opencode" )] async fn oc_session_abort( - State(_state): State>, - Path(_session_id): Path, + State(state): State>, + Path(session_id): Path, ) -> impl IntoResponse { - bool_ok(true) + let updated = state + .opencode + .update_session_status(&session_id, "idle") + .await; + if updated.is_none() { + return not_found("Session not found").into_response(); + } + state.opencode.emit_event(json!({ + "type": "session.status", + "properties": {"sessionID": session_id, "status": {"type": "idle"}} + })); + state.opencode.emit_event(json!({ + "type": "session.idle", + "properties": {"sessionID": session_id} + })); + bool_ok(true).into_response() } #[utoipa::path( @@ -2763,8 +3331,27 @@ async fn oc_session_abort( responses((status = 200)), tag = "opencode" )] -async fn oc_session_children() -> impl IntoResponse { - (StatusCode::OK, Json(json!([]))) +async fn oc_session_children( + State(state): State>, + Path(session_id): Path, +) -> impl IntoResponse { + let sessions = state.opencode.sessions.lock().await; + if !sessions.contains_key(&session_id) { + return not_found("Session not found").into_response(); + } + let mut children: Vec = sessions + .values() + .filter(|session| session.parent_id.as_deref() == Some(&session_id)) + .cloned() + .collect(); + drop(sessions); + children.sort_by(|a, b| { + a.created_at + .cmp(&b.created_at) + .then_with(|| a.id.cmp(&b.id)) + }); + let response: Vec = children.into_iter().map(|s| s.to_value()).collect(); + (StatusCode::OK, Json(json!(response))).into_response() } #[utoipa::path( @@ -2774,8 +3361,19 @@ async fn oc_session_children() -> impl IntoResponse { responses((status = 200)), tag = "opencode" )] -async fn oc_session_init() -> impl IntoResponse { - bool_ok(true) +async fn oc_session_init( + State(state): State>, + Path(session_id): Path, + headers: HeaderMap, + Query(query): Query, +) -> impl IntoResponse { + let directory = state.opencode.directory_for(&headers, query.directory.as_ref()); + let _ = state.opencode.ensure_session(&session_id, directory).await; + let _ = state + .opencode + .update_session_status(&session_id, "idle") + .await; + bool_ok(true).into_response() } #[utoipa::path( @@ -2789,25 +3387,38 @@ async fn oc_session_init() -> impl IntoResponse { async fn oc_session_fork( State(state): State>, Path(session_id): Path, - headers: HeaderMap, - Query(query): Query, ) -> impl IntoResponse { - let directory = state.opencode.directory_for(&headers, query.directory.as_ref()); let now = state.opencode.now_ms(); + let parent = { + let sessions = state.opencode.sessions.lock().await; + sessions.get(&session_id).cloned() + }; + let Some(parent) = parent else { + return not_found("Session not found").into_response(); + }; + let (directory, project_id, title, version) = ( + parent.directory, + parent.project_id, + format!("Fork of {}", parent.title), + parent.version, + ); let id = next_id("ses_", &SESSION_COUNTER); let slug = format!("session-{}", id); - let title = format!("Fork of {}", session_id); let record = OpenCodeSessionRecord { id: id.clone(), slug, - project_id: state.opencode.default_project_id.clone(), + project_id, directory, parent_id: Some(session_id), title, - version: "0".to_string(), + version, created_at: now, updated_at: now, share_url: None, + status: OpenCodeSessionStatus { + status: "idle".to_string(), + updated_at: now, + }, }; let value = record.to_value(); @@ -2818,6 +3429,7 @@ async fn oc_session_fork( state .opencode .emit_event(session_event("session.created", &value)); + state.opencode.persist_sessions().await; (StatusCode::OK, Json(value)) } @@ -2829,8 +3441,15 @@ async fn oc_session_fork( responses((status = 200)), tag = "opencode" )] -async fn oc_session_diff() -> impl IntoResponse { - (StatusCode::OK, Json(json!([]))) +async fn oc_session_diff( + State(state): State>, + Path(session_id): Path, +) -> impl IntoResponse { + let sessions = state.opencode.sessions.lock().await; + if !sessions.contains_key(&session_id) { + return not_found("Session not found").into_response(); + } + (StatusCode::OK, Json(json!([]))).into_response() } #[utoipa::path( @@ -2926,6 +3545,10 @@ async fn oc_session_message_create( "status": {"type": "busy"} } })); + let _ = state + .opencode + .update_session_status(&session_id, "busy") + .await; let mut user_message = build_user_message( &session_id, @@ -3246,6 +3869,22 @@ async fn oc_session_revert( headers: HeaderMap, Query(query): Query, ) -> impl IntoResponse { + let now = state.opencode.now_ms(); + let updated = state + .opencode + .mutate_session(&session_id, |session| { + session.version = bump_version(&session.version); + session.updated_at = now; + }) + .await; + if let Some(session) = updated { + let value = session.to_value(); + state + .opencode + .emit_event(session_event("session.updated", &value)); + state.opencode.persist_sessions().await; + return (StatusCode::OK, Json(value)).into_response(); + } oc_session_get(State(state), Path(session_id), headers, Query(query)).await } @@ -3263,6 +3902,22 @@ async fn oc_session_unrevert( headers: HeaderMap, Query(query): Query, ) -> impl IntoResponse { + let now = state.opencode.now_ms(); + let updated = state + .opencode + .mutate_session(&session_id, |session| { + session.version = bump_version(&session.version); + session.updated_at = now; + }) + .await; + if let Some(session) = updated { + let value = session.to_value(); + state + .opencode + .emit_event(session_event("session.updated", &value)); + state.opencode.persist_sessions().await; + return (StatusCode::OK, Json(value)).into_response(); + } oc_session_get(State(state), Path(session_id), headers, Query(query)).await } @@ -3308,10 +3963,20 @@ async fn oc_session_share( State(state): State>, Path(session_id): Path, ) -> impl IntoResponse { - let mut sessions = state.opencode.sessions.lock().await; - if let Some(session) = sessions.get_mut(&session_id) { - session.share_url = Some(format!("https://share.local/{}", session_id)); + let now = state.opencode.now_ms(); + let updated = state + .opencode + .mutate_session(&session_id, |session| { + session.share_url = Some(format!("https://share.local/{}", session_id)); + session.updated_at = now; + }) + .await; + if let Some(session) = updated { let value = session.to_value(); + state + .opencode + .emit_event(session_event("session.updated", &value)); + state.opencode.persist_sessions().await; return (StatusCode::OK, Json(value)).into_response(); } not_found("Session not found").into_response() @@ -3328,10 +3993,20 @@ async fn oc_session_unshare( State(state): State>, Path(session_id): Path, ) -> impl IntoResponse { - let mut sessions = state.opencode.sessions.lock().await; - if let Some(session) = sessions.get_mut(&session_id) { - session.share_url = None; + let now = state.opencode.now_ms(); + let updated = state + .opencode + .mutate_session(&session_id, |session| { + session.share_url = None; + session.updated_at = now; + }) + .await; + if let Some(session) = updated { let value = session.to_value(); + state + .opencode + .emit_event(session_event("session.updated", &value)); + state.opencode.persist_sessions().await; return (StatusCode::OK, Json(value)).into_response(); } not_found("Session not found").into_response() @@ -3344,8 +4019,15 @@ async fn oc_session_unshare( responses((status = 200)), tag = "opencode" )] -async fn oc_session_todo() -> impl IntoResponse { - (StatusCode::OK, Json(json!([]))) +async fn oc_session_todo( + State(state): State>, + Path(session_id): Path, +) -> impl IntoResponse { + let sessions = state.opencode.sessions.lock().await; + if !sessions.contains_key(&session_id) { + return not_found("Session not found").into_response(); + } + (StatusCode::OK, Json(json!([]))).into_response() } #[utoipa::path( @@ -3621,8 +4303,8 @@ async fn oc_auth_remove(Path(_provider_id): Path) -> impl IntoResponse { tag = "opencode" )] async fn oc_pty_list(State(state): State>) -> impl IntoResponse { - let ptys = state.opencode.ptys.lock().await; - let values: Vec = ptys.values().map(|p| p.to_value()).collect(); + let ptys = state.inner.session_manager().pty_manager().list().await; + let values: Vec = ptys.iter().map(pty_to_value).collect(); (StatusCode::OK, Json(json!(values))) } @@ -3641,23 +4323,34 @@ async fn oc_pty_create( ) -> impl IntoResponse { let directory = state.opencode.directory_for(&headers, query.directory.as_ref()); let id = next_id("pty_", &PTY_COUNTER); - let record = OpenCodePtyRecord { + let owner_session_id = headers + .get("x-opencode-session") + .and_then(|value| value.to_str().ok()) + .map(|value| value.to_string()); + let options = PtyCreateOptions { id: id.clone(), title: body.title.unwrap_or_else(|| "PTY".to_string()), command: body.command.unwrap_or_else(|| "bash".to_string()), args: body.args.unwrap_or_default(), cwd: body.cwd.unwrap_or_else(|| directory), - status: "running".to_string(), - pid: 0, + env: body.env.unwrap_or_default(), + owner_session_id, }; - let value = record.to_value(); - let mut ptys = state.opencode.ptys.lock().await; - ptys.insert(id, record); - drop(ptys); + let record = match state + .inner + .session_manager() + .pty_manager() + .create(options) + .await + { + Ok(record) => record, + Err(err) => return internal_error(&err.to_string()).into_response(), + }; + let value = pty_to_value(&record); state .opencode - .emit_event(json!({"type": "pty.created", "properties": {"pty": value}})); + .emit_event(json!({"type": "pty.created", "properties": {"info": value}})); (StatusCode::OK, Json(value)) } @@ -3673,9 +4366,14 @@ async fn oc_pty_get( State(state): State>, Path(pty_id): Path, ) -> impl IntoResponse { - let ptys = state.opencode.ptys.lock().await; - if let Some(pty) = ptys.get(&pty_id) { - return (StatusCode::OK, Json(pty.to_value())).into_response(); + if let Some(pty) = state + .inner + .session_manager() + .pty_manager() + .get(&pty_id) + .await + { + return (StatusCode::OK, Json(pty_to_value(&pty))).into_response(); } not_found("PTY not found").into_response() } @@ -3684,33 +4382,37 @@ async fn oc_pty_get( put, path = "/pty/{ptyID}", params(("ptyID" = String, Path, description = "Pty ID")), - request_body = String, + request_body = PtyUpdateRequest, responses((status = 200), (status = 404)), tag = "opencode" )] async fn oc_pty_update( State(state): State>, Path(pty_id): Path, - Json(body): Json, + Json(body): Json, ) -> impl IntoResponse { - let mut ptys = state.opencode.ptys.lock().await; - if let Some(pty) = ptys.get_mut(&pty_id) { - if let Some(title) = body.title { - pty.title = title; - } - if let Some(command) = body.command { - pty.command = command; - } - if let Some(args) = body.args { - pty.args = args; - } - if let Some(cwd) = body.cwd { - pty.cwd = cwd; - } - let value = pty.to_value(); + let options = PtyUpdateOptions { + title: body.title, + size: body.size.map(|size| PtySizeSpec { + rows: size.rows, + cols: size.cols, + }), + }; + let updated = match state + .inner + .session_manager() + .pty_manager() + .update(&pty_id, options) + .await + { + Ok(updated) => updated, + Err(err) => return internal_error(&err.to_string()).into_response(), + }; + if let Some(pty) = updated { + let value = pty_to_value(&pty); state .opencode - .emit_event(json!({"type": "pty.updated", "properties": {"pty": value}})); + .emit_event(json!({"type": "pty.updated", "properties": {"info": value}})); return (StatusCode::OK, Json(value)).into_response(); } not_found("PTY not found").into_response() @@ -3727,11 +4429,17 @@ async fn oc_pty_delete( State(state): State>, Path(pty_id): Path, ) -> impl IntoResponse { - let mut ptys = state.opencode.ptys.lock().await; - if let Some(pty) = ptys.remove(&pty_id) { + if state + .inner + .session_manager() + .pty_manager() + .remove(&pty_id) + .await + .is_some() + { state .opencode - .emit_event(json!({"type": "pty.deleted", "properties": {"pty": pty.to_value()}})); + .emit_event(json!({"type": "pty.deleted", "properties": {"id": pty_id}})); return bool_ok(true).into_response(); } not_found("PTY not found").into_response() @@ -3744,8 +4452,70 @@ async fn oc_pty_delete( responses((status = 200)), tag = "opencode" )] -async fn oc_pty_connect(Path(_pty_id): Path) -> impl IntoResponse { - bool_ok(true) +async fn oc_pty_connect( + State(state): State>, + Path(pty_id): Path, + ws: Option, +) -> impl IntoResponse { + let io = match state + .inner + .session_manager() + .pty_manager() + .connect(&pty_id) + .await + { + Some(io) => io, + None => return not_found("PTY not found").into_response(), + }; + + if let Some(ws) = ws { + return ws.on_upgrade(move |socket| handle_pty_socket(socket, io)).into_response(); + } + + let stream = ReceiverStream::new(io.output).map(|chunk| { + let text = String::from_utf8_lossy(&chunk).to_string(); + Ok::(Event::default().data(text)) + }); + Sse::new(stream) + .keep_alive(KeepAlive::new().interval(std::time::Duration::from_secs(15))) + .into_response() +} + +async fn handle_pty_socket(socket: WebSocket, io: PtyIo) { + let (mut sender, mut receiver) = socket.split(); + let mut output_rx = io.output; + let input_tx = io.input; + + let output_task = tokio::spawn(async move { + while let Some(chunk) = output_rx.recv().await { + let text = String::from_utf8_lossy(&chunk).to_string(); + if sender.send(Message::Text(text)).await.is_err() { + break; + } + } + }); + + let input_task = tokio::spawn(async move { + while let Some(message) = receiver.next().await { + match message { + Ok(Message::Text(text)) => { + if input_tx.send(text.into_bytes()).await.is_err() { + break; + } + } + Ok(Message::Binary(bytes)) => { + if input_tx.send(bytes).await.is_err() { + break; + } + } + Ok(Message::Close(_)) => break, + Ok(Message::Ping(_)) | Ok(Message::Pong(_)) => {} + Err(_) => break, + } + } + }); + + let _ = tokio::join!(output_task, input_task); } #[utoipa::path( @@ -3788,17 +4558,308 @@ async fn oc_file_status() -> impl IntoResponse { (StatusCode::OK, Json(json!([]))).into_response() } +fn parse_find_limit(limit: Option) -> Result)> { + let limit = limit.unwrap_or(FIND_MAX_RESULTS); + if limit == 0 || limit > FIND_MAX_RESULTS { + return Err(bad_request("limit must be between 1 and 200")); + } + Ok(limit) +} + +fn resolve_find_root( + state: &OpenCodeAppState, + headers: &HeaderMap, + directory: Option<&String>, +) -> Result)> { + let directory = state.opencode.directory_for(headers, directory); + let root = PathBuf::from(directory); + let root = root + .canonicalize() + .map_err(|_| bad_request("directory not found"))?; + if !root.is_dir() { + return Err(bad_request("directory not found")); + } + Ok(root) +} + +fn normalize_path(path: &FsPath) -> String { + path.to_string_lossy().replace('\\', "/") +} + +fn opencode_symbol_kind(kind: &str) -> u32 { + match kind { + "class" => 5, + "interface" | "trait" => 11, + "struct" => 23, + "enum" => 10, + "function" => 12, + _ => 12, + } +} + +fn has_wildcards(query: &str) -> bool { + query.contains('*') || query.contains('?') +} + +fn wildcard_match(pattern: &str, text: &str) -> bool { + let pattern = pattern.as_bytes(); + let text = text.as_bytes(); + let mut pattern_index = 0; + let mut text_index = 0; + let mut star_index: Option = None; + let mut match_index = 0; + + while text_index < text.len() { + if pattern_index < pattern.len() + && (pattern[pattern_index] == b'?' || pattern[pattern_index] == text[text_index]) + { + pattern_index += 1; + text_index += 1; + continue; + } + + if pattern_index < pattern.len() && pattern[pattern_index] == b'*' { + star_index = Some(pattern_index); + match_index = text_index; + pattern_index += 1; + continue; + } + + if let Some(star) = star_index { + pattern_index = star + 1; + match_index += 1; + text_index = match_index; + continue; + } + + return false; + } + + while pattern_index < pattern.len() && pattern[pattern_index] == b'*' { + pattern_index += 1; + } + + pattern_index == pattern.len() +} + +fn matches_find_query(candidate: &str, query: &str, use_wildcards: bool) -> bool { + if use_wildcards { + return wildcard_match(query, candidate); + } + candidate.contains(query) +} + +fn parse_find_entry_type( + entry_type: Option<&str>, + dirs: Option, +) -> Result<(bool, bool), (StatusCode, Json)> { + match entry_type { + Some("file") => Ok((true, false)), + Some("directory") => Ok((false, true)), + Some(_) => Err(bad_request("type must be file or directory")), + None => Ok((true, dirs.unwrap_or(false))), + } +} + +fn find_files_in_root( + root: &FsPath, + query: &str, + include_files: bool, + include_dirs: bool, + limit: usize, +) -> Vec { + let mut results = Vec::new(); + let mut queue = VecDeque::new(); + let query_lower = query.to_ascii_lowercase(); + let use_wildcards = has_wildcards(&query_lower); + + queue.push_back(root.to_path_buf()); + + while let Some(dir) = queue.pop_front() { + let entries = match fs::read_dir(&dir) { + Ok(entries) => entries, + Err(_) => continue, + }; + + for entry in entries.flatten() { + let path = entry.path(); + let file_name = entry.file_name().to_string_lossy().to_string(); + let file_name_lower = file_name.to_ascii_lowercase(); + + if path.is_dir() { + if FIND_IGNORE_DIRS.iter().any(|name| *name == file_name) { + continue; + } + queue.push_back(path.clone()); + } + + let is_file = path.is_file(); + let is_dir = path.is_dir(); + if (is_file && !include_files) || (is_dir && !include_dirs) { + continue; + } + + let relative = path.strip_prefix(root).unwrap_or(&path); + let relative_text = normalize_path(relative); + if relative_text.is_empty() { + continue; + } + let relative_lower = relative_text.to_ascii_lowercase(); + + if matches_find_query(&relative_lower, &query_lower, use_wildcards) + || matches_find_query(&file_name_lower, &query_lower, use_wildcards) + { + results.push(relative_text); + if results.len() >= limit { + return results; + } + } + } + } + + results +} + +async fn rg_matches( + root: &FsPath, + pattern: &str, + limit: usize, + case_sensitive: Option, +) -> Result, (StatusCode, Json)> { + let mut command = Command::new("rg"); + command + .arg("--json") + .arg("--line-number") + .arg("--byte-offset") + .arg("--with-filename") + .arg("--max-count") + .arg(limit.to_string()); + if case_sensitive == Some(false) { + command.arg("--ignore-case"); + } + command.arg(pattern); + command.current_dir(root); + + let output = command + .output() + .await + .map_err(|_| internal_error("ripgrep failed"))?; + if !output.status.success() { + if output.status.code() != Some(1) { + return Err(internal_error("ripgrep failed")); + } + } + + let mut results = Vec::new(); + for line in output.stdout.split(|b| *b == b'\n') { + if line.is_empty() { + continue; + } + let value: Value = serde_json::from_slice(line) + .map_err(|_| internal_error("invalid ripgrep output"))?; + if value.get("type").and_then(|v| v.as_str()) != Some("match") { + continue; + } + if let Some(data) = value.get("data") { + results.push(data.clone()); + if results.len() >= limit { + break; + } + } + } + + Ok(results) +} + +fn symbol_from_match(root: &FsPath, data: &Value) -> Option { + let path_text = data + .get("path") + .and_then(|v| v.get("text")) + .and_then(|v| v.as_str())?; + let line_number = data.get("line_number").and_then(|v| v.as_u64())?; + let submatch = data + .get("submatches") + .and_then(|v| v.as_array()) + .and_then(|v| v.first())?; + let match_text = submatch + .get("match") + .and_then(|v| v.get("text")) + .and_then(|v| v.as_str())?; + let start = submatch.get("start").and_then(|v| v.as_u64())?; + let end = submatch.get("end").and_then(|v| v.as_u64())?; + + let path = FsPath::new(path_text); + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + root.join(path) + }; + let uri = format!("file://{}", normalize_path(&absolute)); + let line = line_number.saturating_sub(1); + + Some(json!({ + "name": match_text, + "kind": 12, + "location": { + "uri": uri, + "range": { + "start": {"line": line, "character": start}, + "end": {"line": line, "character": end} + } + } + })) +} + +async fn rg_symbols( + root: &FsPath, + query: &str, + limit: usize, +) -> Result, (StatusCode, Json)> { + let matches = rg_matches(root, query, limit, None).await?; + let mut symbols = Vec::new(); + for data in matches { + if let Some(symbol) = symbol_from_match(root, &data) { + symbols.push(symbol); + if symbols.len() >= limit { + break; + } + } + } + Ok(symbols) +} + #[utoipa::path( get, path = "/find", responses((status = 200)), tag = "opencode" )] -async fn oc_find_text(Query(query): Query) -> impl IntoResponse { - if query.pattern.is_none() { - return bad_request("pattern is required").into_response(); +async fn oc_find_text( + State(state): State>, + headers: HeaderMap, + Query(query): Query, +) -> impl IntoResponse { + let pattern = match query.pattern.as_deref().map(str::trim).filter(|v| !v.is_empty()) { + Some(value) => value, + None => return bad_request("pattern is required").into_response(), + }; + let limit = match query.limit { + Some(value) if value == 0 || value > 200 => { + return bad_request("limit must be between 1 and 200").into_response(); + } + Some(value) => Some(value), + None => None, + }; + let directory = state.opencode.directory_for(&headers, query.directory.as_ref()); + match state + .inner + .session_manager() + .find_text(&directory, pattern, query.case_sensitive, limit) + .await + { + Ok(results) => (StatusCode::OK, Json(results)).into_response(), + Err(err) => sandbox_error_response(err).into_response(), } - (StatusCode::OK, Json(json!([]))).into_response() } #[utoipa::path( @@ -3807,11 +4868,43 @@ async fn oc_find_text(Query(query): Query) -> impl IntoResponse { responses((status = 200)), tag = "opencode" )] -async fn oc_find_files(Query(query): Query) -> impl IntoResponse { - if query.query.is_none() { - return bad_request("query is required").into_response(); +async fn oc_find_files( + State(state): State>, + headers: HeaderMap, + Query(query): Query, +) -> impl IntoResponse { + let query_value = match query.query.as_deref().map(str::trim).filter(|v| !v.is_empty()) { + Some(value) => value, + None => return bad_request("query is required").into_response(), + }; + let kind = match query.entry_type.as_deref() { + Some("file") => FindFileKind::File, + Some("directory") => FindFileKind::Directory, + Some(_) => return bad_request("type must be file or directory").into_response(), + None => { + if query.dirs.unwrap_or(false) { + FindFileKind::Any + } else { + FindFileKind::File + } + } + }; + let limit = query.limit.unwrap_or(200).min(200).max(1); + let options = FindFileOptions { + kind, + case_sensitive: false, + limit: Some(limit), + }; + let directory = state.opencode.directory_for(&headers, query.directory.as_ref()); + match state + .inner + .session_manager() + .find_files(&directory, query_value, options) + .await + { + Ok(results) => (StatusCode::OK, Json(results)).into_response(), + Err(err) => sandbox_error_response(err).into_response(), } - (StatusCode::OK, Json(json!([]))).into_response() } #[utoipa::path( @@ -3820,11 +4913,54 @@ async fn oc_find_files(Query(query): Query) -> impl IntoResponse responses((status = 200)), tag = "opencode" )] -async fn oc_find_symbols(Query(query): Query) -> impl IntoResponse { - if query.query.is_none() { - return bad_request("query is required").into_response(); +async fn oc_find_symbols( + State(state): State>, + headers: HeaderMap, + Query(query): Query, +) -> impl IntoResponse { + let query_value = match query.query.as_deref().map(str::trim).filter(|v| !v.is_empty()) { + Some(value) => value, + None => return bad_request("query is required").into_response(), + }; + let limit = match query.limit { + Some(value) if value == 0 || value > 200 => { + return bad_request("limit must be between 1 and 200").into_response(); + } + Some(value) => Some(value), + None => None, + }; + let directory = state.opencode.directory_for(&headers, query.directory.as_ref()); + match state + .inner + .session_manager() + .find_symbols(&directory, query_value, limit) + .await + { + Ok(results) => { + let root = PathBuf::from(&directory); + let symbols: Vec = results + .into_iter() + .map(|symbol| { + let path = PathBuf::from(&symbol.path); + let absolute = if path.is_absolute() { path } else { root.join(&symbol.path) }; + let uri = format!("file://{}", normalize_path(&absolute)); + json!({ + "name": symbol.name, + "kind": opencode_symbol_kind(&symbol.kind), + "location": { + "uri": uri, + "range": { + "start": {"line": symbol.line.saturating_sub(1), "character": symbol.column.saturating_sub(1)}, + "end": {"line": symbol.line.saturating_sub(1), "character": symbol.column.saturating_sub(1)} + } + } + }) + }) + .collect(); + (StatusCode::OK, Json(symbols)).into_response() + } + Err(err) => sandbox_error_response(err).into_response(), } - (StatusCode::OK, Json(json!([]))).into_response() } #[utoipa::path( @@ -3833,18 +4969,41 @@ async fn oc_find_symbols(Query(query): Query) -> impl IntoResp responses((status = 200)), tag = "opencode" )] -async fn oc_mcp_list() -> impl IntoResponse { - (StatusCode::OK, Json(json!({}))) +async fn oc_mcp_list(State(state): State>) -> impl IntoResponse { + let statuses = state.inner.session_manager().mcp_statuses().await; + let mut map = serde_json::Map::new(); + for (name, status) in statuses { + map.insert(name, status.to_value()); + } + (StatusCode::OK, Json(Value::Object(map))) } #[utoipa::path( post, path = "/mcp", + request_body = OpenCodeMcpRegisterRequest, responses((status = 200)), tag = "opencode" )] -async fn oc_mcp_register() -> impl IntoResponse { - (StatusCode::OK, Json(json!({}))) +async fn oc_mcp_register( + State(state): State>, + Json(body): Json, +) -> impl IntoResponse { + match state + .inner + .session_manager() + .mcp_register(body.name, body.config) + .await + { + Ok(statuses) => { + let mut map = serde_json::Map::new(); + for (name, status) in statuses { + map.insert(name, status.to_value()); + } + (StatusCode::OK, Json(Value::Object(map))).into_response() + } + Err(err) => mcp_error_response(err).into_response(), + } } #[utoipa::path( @@ -3855,10 +5014,18 @@ async fn oc_mcp_register() -> impl IntoResponse { tag = "opencode" )] async fn oc_mcp_auth( - Path(_name): Path, + State(state): State>, + Path(name): Path, _body: Option>, ) -> impl IntoResponse { - (StatusCode::OK, Json(json!({"status": "needs_auth"}))) + match state.inner.session_manager().mcp_auth_start(&name).await { + Ok(url) => ( + StatusCode::OK, + Json(json!({"authorizationUrl": url})), + ) + .into_response(), + Err(err) => mcp_error_response(err).into_response(), + } } #[utoipa::path( @@ -3868,37 +5035,56 @@ async fn oc_mcp_auth( responses((status = 200)), tag = "opencode" )] -async fn oc_mcp_auth_remove(Path(_name): Path) -> impl IntoResponse { - (StatusCode::OK, Json(json!({"status": "disabled"}))) +async fn oc_mcp_auth_remove( + State(state): State>, + Path(name): Path, +) -> impl IntoResponse { + match state.inner.session_manager().mcp_auth_remove(&name).await { + Ok(_) => (StatusCode::OK, Json(json!({"success": true}))).into_response(), + Err(err) => mcp_error_response(err).into_response(), + } } #[utoipa::path( post, path = "/mcp/{name}/auth/callback", params(("name" = String, Path, description = "MCP server name")), + request_body = OpenCodeMcpAuthCallbackRequest, responses((status = 200)), tag = "opencode" )] async fn oc_mcp_auth_callback( - Path(_name): Path, - _body: Option>, + State(state): State>, + Path(name): Path, + Json(body): Json, ) -> impl IntoResponse { - (StatusCode::OK, Json(json!({"status": "needs_auth"}))) + match state + .inner + .session_manager() + .mcp_auth_callback(&name, body.code) + .await + { + Ok(status) => (StatusCode::OK, Json(status.to_value())).into_response(), + Err(err) => mcp_error_response(err).into_response(), + } } #[utoipa::path( post, path = "/mcp/{name}/auth/authenticate", params(("name" = String, Path, description = "MCP server name")), - request_body = String, responses((status = 200)), tag = "opencode" )] async fn oc_mcp_authenticate( - Path(_name): Path, + State(state): State>, + Path(name): Path, _body: Option>, ) -> impl IntoResponse { - (StatusCode::OK, Json(json!({"status": "needs_auth"}))) + match state.inner.session_manager().mcp_auth_authenticate(&name).await { + Ok(status) => (StatusCode::OK, Json(status.to_value())).into_response(), + Err(err) => mcp_error_response(err).into_response(), + } } #[utoipa::path( @@ -3908,8 +5094,14 @@ async fn oc_mcp_authenticate( responses((status = 200)), tag = "opencode" )] -async fn oc_mcp_connect(Path(_name): Path) -> impl IntoResponse { - bool_ok(true) +async fn oc_mcp_connect( + State(state): State>, + Path(name): Path, +) -> impl IntoResponse { + match state.inner.session_manager().mcp_connect(&name).await { + Ok(_) => bool_ok(true).into_response(), + Err(err) => mcp_error_response(err).into_response(), + } } #[utoipa::path( @@ -3919,8 +5111,14 @@ async fn oc_mcp_connect(Path(_name): Path) -> impl IntoResponse { responses((status = 200)), tag = "opencode" )] -async fn oc_mcp_disconnect(Path(_name): Path) -> impl IntoResponse { - bool_ok(true) +async fn oc_mcp_disconnect( + State(state): State>, + Path(name): Path, +) -> impl IntoResponse { + match state.inner.session_manager().mcp_disconnect(&name).await { + Ok(_) => bool_ok(true).into_response(), + Err(err) => mcp_error_response(err).into_response(), + } } #[utoipa::path( @@ -3929,8 +5127,9 @@ async fn oc_mcp_disconnect(Path(_name): Path) -> impl IntoResponse { responses((status = 200)), tag = "opencode" )] -async fn oc_tool_ids() -> impl IntoResponse { - (StatusCode::OK, Json(json!([]))) +async fn oc_tool_ids(State(state): State>) -> impl IntoResponse { + let tool_ids = state.inner.session_manager().mcp_tool_ids().await; + (StatusCode::OK, Json(json!(tool_ids))) } #[utoipa::path( @@ -3939,11 +5138,16 @@ async fn oc_tool_ids() -> impl IntoResponse { responses((status = 200)), tag = "opencode" )] -async fn oc_tool_list(Query(query): Query) -> impl IntoResponse { +async fn oc_tool_list( + State(state): State>, + Query(query): Query, +) -> impl IntoResponse { if query.provider.is_none() || query.model.is_none() { return bad_request("provider and model are required").into_response(); } - (StatusCode::OK, Json(json!([]))).into_response() + let tools = state.inner.session_manager().mcp_tools().await; + let values: Vec = tools.into_iter().map(|tool| tool.to_tool_list_item()).collect(); + (StatusCode::OK, Json(json!(values))).into_response() } #[utoipa::path( @@ -4267,7 +5471,9 @@ async fn oc_tui_select_session() -> impl IntoResponse { PermissionReplyRequest, PermissionGlobalReplyRequest, QuestionReplyBody, - PtyCreateRequest + PtyCreateRequest, + PtySizeRequest, + PtyUpdateRequest )), tags((name = "opencode", description = "OpenCode compatibility API")) )] diff --git a/server/packages/sandbox-agent/src/pty.rs b/server/packages/sandbox-agent/src/pty.rs new file mode 100644 index 0000000..64a4ecd --- /dev/null +++ b/server/packages/sandbox-agent/src/pty.rs @@ -0,0 +1,354 @@ +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize}; +use tokio::sync::{broadcast, mpsc, Mutex as AsyncMutex}; + +use sandbox_agent_error::SandboxError; + +const DEFAULT_ROWS: u16 = 24; +const DEFAULT_COLS: u16 = 80; +const OUTPUT_BUFFER_SIZE: usize = 8192; +const OUTPUT_CHANNEL_CAPACITY: usize = 256; +const INPUT_CHANNEL_CAPACITY: usize = 256; +const EXIT_POLL_INTERVAL_MS: u64 = 200; + +#[derive(Debug, Clone)] +pub struct PtyRecord { + pub id: String, + pub title: String, + pub command: String, + pub args: Vec, + pub cwd: String, + pub status: PtyStatus, + pub pid: i64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PtyStatus { + Running, + Exited, +} + +impl PtyStatus { + pub fn as_str(&self) -> &'static str { + match self { + PtyStatus::Running => "running", + PtyStatus::Exited => "exited", + } + } +} + +#[derive(Debug, Clone)] +pub struct PtySizeSpec { + pub rows: u16, + pub cols: u16, +} + +#[derive(Debug, Clone)] +pub struct PtyCreateOptions { + pub id: String, + pub title: String, + pub command: String, + pub args: Vec, + pub cwd: String, + pub env: HashMap, + pub owner_session_id: Option, +} + +#[derive(Debug, Clone)] +pub struct PtyUpdateOptions { + pub title: Option, + pub size: Option, +} + +#[derive(Debug)] +pub struct PtyIo { + pub output: mpsc::Receiver>, + pub input: mpsc::Sender>, +} + +#[derive(Debug, Clone)] +pub enum PtyEvent { + Exited { id: String, exit_code: i32 }, +} + +#[derive(Debug)] +pub struct PtyManager { + ptys: AsyncMutex>>, + event_tx: broadcast::Sender, +} + +#[derive(Debug)] +struct PtyHandle { + record: Mutex, + master: Mutex>, + input_tx: mpsc::Sender>, + output_listeners: Mutex>>>, + owner_session_id: Option, + child: Mutex>, +} + +#[derive(Debug, Clone)] +struct PtyRecordState { + record: PtyRecord, + exit_code: Option, +} + +impl PtyRecordState { + fn snapshot(&self) -> PtyRecord { + self.record.clone() + } +} + +impl PtyManager { + pub fn new() -> Self { + let (event_tx, _) = broadcast::channel(128); + Self { + ptys: AsyncMutex::new(HashMap::new()), + event_tx, + } + } + + pub fn subscribe(&self) -> broadcast::Receiver { + self.event_tx.subscribe() + } + + pub async fn list(&self) -> Vec { + let ptys = self.ptys.lock().await; + ptys.values() + .map(|handle| handle.record.lock().unwrap().snapshot()) + .collect() + } + + pub async fn get(&self, id: &str) -> Option { + let ptys = self.ptys.lock().await; + ptys.get(id) + .map(|handle| handle.record.lock().unwrap().snapshot()) + } + + pub async fn connect(&self, id: &str) -> Option { + let ptys = self.ptys.lock().await; + let handle = ptys.get(id)?.clone(); + drop(ptys); + + let (output_tx, output_rx) = mpsc::channel(OUTPUT_CHANNEL_CAPACITY); + handle + .output_listeners + .lock() + .unwrap() + .push(output_tx); + + Some(PtyIo { + output: output_rx, + input: handle.input_tx.clone(), + }) + } + + pub async fn create(&self, options: PtyCreateOptions) -> Result { + let pty_system = native_pty_system(); + let pty_size = PtySize { + rows: DEFAULT_ROWS, + cols: DEFAULT_COLS, + pixel_width: 0, + pixel_height: 0, + }; + let pair = pty_system.openpty(pty_size).map_err(|err| SandboxError::StreamError { + message: format!("failed to open pty: {err}"), + })?; + + let mut cmd = CommandBuilder::new(&options.command); + cmd.args(&options.args); + cmd.cwd(&options.cwd); + for (key, value) in &options.env { + cmd.env(key, value); + } + + let child = pair + .slave + .spawn_command(cmd) + .map_err(|err| SandboxError::StreamError { + message: format!("failed to spawn pty command: {err}"), + })?; + let pid = child + .process_id() + .map(|value| value as i64) + .unwrap_or(0); + + let record = PtyRecord { + id: options.id.clone(), + title: options.title.clone(), + command: options.command.clone(), + args: options.args.clone(), + cwd: options.cwd.clone(), + status: PtyStatus::Running, + pid, + }; + let record_state = PtyRecordState { + record: record.clone(), + exit_code: None, + }; + + let mut master = pair.master; + let reader = master + .try_clone_reader() + .map_err(|err| SandboxError::StreamError { + message: format!("failed to clone pty reader: {err}"), + })?; + let writer = master + .take_writer() + .map_err(|err| SandboxError::StreamError { + message: format!("failed to take pty writer: {err}"), + })?; + + let (input_tx, input_rx) = mpsc::channel::>(INPUT_CHANNEL_CAPACITY); + let output_listeners = Mutex::new(Vec::new()); + let handle = Arc::new(PtyHandle { + record: Mutex::new(record_state), + master: Mutex::new(master), + input_tx, + output_listeners, + owner_session_id: options.owner_session_id.clone(), + child: Mutex::new(child), + }); + + self.spawn_output_reader(handle.clone(), reader); + self.spawn_input_writer(writer, input_rx); + self.spawn_exit_watcher(handle.clone()); + + let mut ptys = self.ptys.lock().await; + ptys.insert(options.id.clone(), handle); + drop(ptys); + + Ok(record) + } + + pub async fn update(&self, id: &str, options: PtyUpdateOptions) -> Result, SandboxError> { + let ptys = self.ptys.lock().await; + let handle = match ptys.get(id) { + Some(handle) => handle.clone(), + None => return Ok(None), + }; + drop(ptys); + + if let Some(title) = options.title { + let mut record = handle.record.lock().unwrap(); + record.record.title = title; + } + + if let Some(size) = options.size { + let pty_size = PtySize { + rows: size.rows, + cols: size.cols, + pixel_width: 0, + pixel_height: 0, + }; + handle + .master + .lock() + .unwrap() + .resize(pty_size) + .map_err(|err| SandboxError::StreamError { + message: format!("failed to resize pty: {err}"), + })?; + } + + let record = handle.record.lock().unwrap().snapshot(); + Ok(Some(record)) + } + + pub async fn remove(&self, id: &str) -> Option { + let mut ptys = self.ptys.lock().await; + let handle = ptys.remove(id)?; + drop(ptys); + + let _ = handle.child.lock().unwrap().kill(); + Some(handle.record.lock().unwrap().snapshot()) + } + + pub async fn cleanup_session(&self, session_id: &str) { + let mut ptys = self.ptys.lock().await; + let ids: Vec = ptys + .iter() + .filter_map(|(id, handle)| { + if handle.owner_session_id.as_deref() == Some(session_id) { + Some(id.clone()) + } else { + None + } + }) + .collect(); + for id in ids { + if let Some(handle) = ptys.remove(&id) { + let _ = handle.child.lock().unwrap().kill(); + } + } + } + + fn spawn_output_reader(&self, handle: Arc, mut reader: Box) { + std::thread::spawn(move || { + let mut buffer = vec![0u8; OUTPUT_BUFFER_SIZE]; + loop { + let size = match reader.read(&mut buffer) { + Ok(0) => break, + Ok(size) => size, + Err(_) => break, + }; + let payload: Arc<[u8]> = Arc::from(buffer[..size].to_vec()); + let mut listeners = handle.output_listeners.lock().unwrap(); + listeners.retain(|listener| listener.blocking_send(payload.clone()).is_ok()); + } + }); + } + + fn spawn_input_writer( + &self, + writer: Box, + mut input_rx: mpsc::Receiver>, + ) { + let writer = Arc::new(Mutex::new(writer)); + tokio::spawn(async move { + while let Some(chunk) = input_rx.recv().await { + let writer = writer.clone(); + let result = tokio::task::spawn_blocking(move || { + let mut writer = writer.lock().unwrap(); + writer.write_all(&chunk)?; + writer.flush() + }) + .await; + if result.is_err() { + break; + } + } + }); + } + + fn spawn_exit_watcher(&self, handle: Arc) { + let event_tx = self.event_tx.clone(); + std::thread::spawn(move || loop { + let exit_code = { + let mut child = handle.child.lock().unwrap(); + match child.try_wait() { + Ok(Some(status)) => Some(status.code().unwrap_or(0)), + Ok(None) => None, + Err(_) => Some(1), + } + }; + if let Some(exit_code) = exit_code { + { + let mut record = handle.record.lock().unwrap(); + record.record.status = PtyStatus::Exited; + record.exit_code = Some(exit_code); + } + let _ = event_tx.send(PtyEvent::Exited { + id: handle.record.lock().unwrap().record.id.clone(), + exit_code, + }); + break; + } + std::thread::sleep(Duration::from_millis(EXIT_POLL_INTERVAL_MS)); + }); + } +} diff --git a/server/packages/sandbox-agent/src/router.rs b/server/packages/sandbox-agent/src/router.rs index 3ca437a..a1068c1 100644 --- a/server/packages/sandbox-agent/src/router.rs +++ b/server/packages/sandbox-agent/src/router.rs @@ -2,7 +2,7 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::convert::Infallible; use std::io::{BufRead, BufReader, Write}; use std::net::TcpListener; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::atomic::{AtomicI64, AtomicU64, Ordering}; use std::sync::{Arc, Weak}; @@ -18,6 +18,7 @@ use axum::Json; use axum::Router; use futures::{stream, StreamExt}; use reqwest::Client; +use reqwest::header::{HeaderMap as ReqwestHeaderMap, HeaderName as ReqwestHeaderName, HeaderValue as ReqwestHeaderValue}; use sandbox_agent_error::{AgentError, ErrorType, ProblemDetails, SandboxError}; use sandbox_agent_universal_agent_schema::{ codex as codex_schema, convert_amp, convert_claude, convert_codex, convert_opencode, @@ -39,7 +40,10 @@ use tracing::Span; use utoipa::{Modify, OpenApi, ToSchema}; use crate::agent_server_logs::AgentServerLogs; +use crate::formatter_lsp::{FormatterService, FormatterStatus, LspRegistry, LspStatus}; use crate::opencode_compat::{build_opencode_router, OpenCodeAppState}; +use crate::search::{FindFileOptions, FindSymbolOptions, FindTextMatch, FindTextOptions, SearchService, SymbolResult}; +use crate::pty::PtyManager; use crate::ui; use sandbox_agent_agent_management::agents::{ AgentError as ManagerError, AgentId, AgentManager, InstallOptions, SpawnOptions, StreamingSpawn, @@ -346,6 +350,65 @@ struct SessionState { claude_message_counter: u64, pending_assistant_native_ids: VecDeque, pending_assistant_counter: u64, + tool_calls: HashMap, + file_actions: Vec, + base_dir: PathBuf, +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum ToolCallStatus { + Started, + Delta, + Completed, + Running, + Result, + Failed, +} + +#[derive(Debug, Clone)] +struct ToolCallRecord { + call_id: String, + name: Option, + arguments: Option, + output: Option, + status: ToolCallStatus, + started_at: Option, + completed_at: Option, + last_delta: Option, + item_id: Option, + native_item_id: Option, +} + +#[derive(Debug, Clone)] +pub(crate) struct ToolCallSnapshot { + pub call_id: String, + pub name: Option, + pub arguments: Option, + pub output: Option, + pub status: ToolCallStatus, + pub started_at: Option, + pub completed_at: Option, + pub last_delta: Option, +} + +#[derive(Debug, Clone)] +struct FileActionRecord { + event_sequence: u64, + call_id: Option, + action: FileAction, + path: String, + target_path: Option, + diff: Option, + applied: bool, +} + +#[derive(Debug, Clone)] +pub(crate) struct FileActionSnapshot { + pub action: FileAction, + pub path: String, + pub target_path: Option, + pub diff: Option, + pub applied: bool, } #[derive(Debug, Clone)] @@ -360,6 +423,7 @@ struct PendingQuestion { options: Vec, } + impl SessionState { fn new( session_id: String, @@ -404,6 +468,9 @@ impl SessionState { claude_message_counter: 0, pending_assistant_native_ids: VecDeque::new(), pending_assistant_counter: 0, + tool_calls: HashMap::new(), + file_actions: Vec::new(), + base_dir: normalize_base_dir(), }) } @@ -620,6 +687,7 @@ impl SessionState { self.update_pending(&event); self.update_item_tracking(&event); + self.update_tool_tracking(&event); self.events.push(event.clone()); let _ = self.broadcaster.send(event.clone()); if self.native_session_id.is_none() { @@ -689,6 +757,218 @@ impl SessionState { } } + fn update_tool_tracking(&mut self, event: &UniversalEvent) { + match event.event_type { + UniversalEventType::ItemStarted | UniversalEventType::ItemCompleted => { + if let UniversalEventData::Item(data) = &event.data { + self.update_tool_call_from_item(event, &data.item); + if event.event_type == UniversalEventType::ItemCompleted { + self.record_file_actions(event, &data.item); + } + } + } + UniversalEventType::ItemDelta => { + if let UniversalEventData::ItemDelta(data) = &event.data { + self.update_tool_call_delta(event, data); + } + } + _ => {} + } + } + + fn update_tool_call_from_item(&mut self, event: &UniversalEvent, item: &UniversalItem) { + let mut call_id = None; + let mut name = None; + let mut arguments = None; + let mut output = None; + for part in &item.content { + match part { + ContentPart::ToolCall { + name: part_name, + arguments: part_arguments, + call_id: part_call_id, + } => { + call_id = Some(part_call_id.clone()); + name = Some(part_name.clone()); + arguments = Some(part_arguments.clone()); + } + ContentPart::ToolResult { call_id: part_call_id, output: part_output } => { + call_id = Some(part_call_id.clone()); + output = Some(part_output.clone()); + } + _ => {} + } + } + + let Some(call_id) = call_id else { + return; + }; + + let record = self.tool_calls.entry(call_id.clone()).or_insert_with(|| ToolCallRecord { + call_id: call_id.clone(), + name: None, + arguments: None, + output: None, + status: ToolCallStatus::Started, + started_at: None, + completed_at: None, + last_delta: None, + item_id: None, + native_item_id: None, + }); + + if name.is_some() { + record.name = name; + } + if arguments.is_some() { + record.arguments = arguments; + } + if output.is_some() { + record.output = output; + } + if !item.item_id.is_empty() { + record.item_id = Some(item.item_id.clone()); + } + if let Some(native) = &item.native_item_id { + record.native_item_id = Some(native.clone()); + } + + match item.kind { + ItemKind::ToolCall => { + match event.event_type { + UniversalEventType::ItemStarted => { + record.status = ToolCallStatus::Started; + record.started_at = Some(event.time.clone()); + } + UniversalEventType::ItemCompleted => { + record.status = ToolCallStatus::Completed; + record.completed_at = Some(event.time.clone()); + } + _ => {} + } + } + ItemKind::ToolResult => { + match event.event_type { + UniversalEventType::ItemStarted => { + record.status = ToolCallStatus::Running; + record.started_at.get_or_insert_with(|| event.time.clone()); + } + UniversalEventType::ItemCompleted => { + if matches!(item.status, ItemStatus::Failed) { + record.status = ToolCallStatus::Failed; + } else { + record.status = ToolCallStatus::Result; + } + record.completed_at = Some(event.time.clone()); + } + _ => {} + } + } + _ => {} + } + } + + fn update_tool_call_delta(&mut self, event: &UniversalEvent, data: &ItemDeltaData) { + let call_id = self + .tool_calls + .iter() + .find_map(|(call_id, record)| { + if record.item_id.as_deref() == Some(data.item_id.as_str()) + || record.native_item_id.as_deref() == data.native_item_id.as_deref() + { + Some(call_id.clone()) + } else { + None + } + }); + let Some(call_id) = call_id else { + return; + }; + if let Some(record) = self.tool_calls.get_mut(&call_id) { + record.status = ToolCallStatus::Delta; + record.last_delta = Some(data.delta.clone()); + record.started_at.get_or_insert_with(|| event.time.clone()); + } + } + + fn record_file_actions(&mut self, event: &UniversalEvent, item: &UniversalItem) { + let mut call_id: Option = None; + for part in &item.content { + match part { + ContentPart::ToolResult { call_id: part_call_id, .. } + | ContentPart::ToolCall { call_id: part_call_id, .. } => { + call_id = Some(part_call_id.clone()); + } + _ => {} + } + } + + for part in &item.content { + let ContentPart::FileRef { + path, + action, + diff, + target_path, + } = part + else { + continue; + }; + + let applied = match action { + FileAction::Write + | FileAction::Patch + | FileAction::Rename + | FileAction::Delete => { + apply_file_action( + &self.base_dir, + action, + path, + target_path.as_deref(), + diff.as_deref(), + ) + } + FileAction::Read => false, + }; + + self.file_actions.push(FileActionRecord { + event_sequence: event.sequence, + call_id: call_id.clone(), + action: action.clone(), + path: path.clone(), + target_path: target_path.clone(), + diff: diff.clone(), + applied, + }); + } + } + + fn tool_call_snapshot(&self, call_id: &str) -> Option { + self.tool_calls.get(call_id).map(|record| ToolCallSnapshot { + call_id: record.call_id.clone(), + name: record.name.clone(), + arguments: record.arguments.clone(), + output: record.output.clone(), + status: record.status.clone(), + started_at: record.started_at.clone(), + completed_at: record.completed_at.clone(), + last_delta: record.last_delta.clone(), + }) + } + + fn file_actions_for_event(&self, sequence: u64) -> Vec { + self.file_actions + .iter() + .filter(|record| record.event_sequence == sequence) + .map(|record| FileActionSnapshot { + action: record.action.clone(), + path: record.path.clone(), + target_path: record.target_path.clone(), + diff: record.diff.clone(), + applied: record.applied, + }) + .collect() + } + fn take_question(&mut self, question_id: &str) -> Option { self.pending_questions.remove(question_id) } @@ -780,6 +1060,188 @@ impl SessionState { } } +fn normalize_base_dir() -> PathBuf { + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + normalize_path(&cwd) +} + +fn normalize_path(path: &Path) -> PathBuf { + use std::path::Component; + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::Prefix(prefix) => normalized.push(prefix.as_os_str()), + Component::RootDir => normalized.push(component.as_os_str()), + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + Component::Normal(part) => normalized.push(part), + } + } + if normalized.as_os_str().is_empty() { + PathBuf::from(".") + } else { + normalized + } +} + +fn scoped_path(base_dir: &Path, path: &str) -> Option { + let candidate = Path::new(path); + let joined = if candidate.is_absolute() { + candidate.to_path_buf() + } else { + base_dir.join(candidate) + }; + let normalized = normalize_path(&joined); + if normalized.starts_with(base_dir) { + Some(normalized) + } else { + None + } +} + +fn apply_file_action( + base_dir: &Path, + action: &FileAction, + path: &str, + target_path: Option<&str>, + diff: Option<&str>, +) -> bool { + match action { + FileAction::Write => { + let Some(contents) = diff else { + return false; + }; + let Some(scoped) = scoped_path(base_dir, path) else { + return false; + }; + if let Some(parent) = scoped.parent() { + if std::fs::create_dir_all(parent).is_err() { + return false; + } + } + std::fs::write(scoped, contents).is_ok() + } + FileAction::Patch => { + let Some(patch) = diff else { + return false; + }; + let Some(scoped) = scoped_path(base_dir, path) else { + return false; + }; + let Ok(original) = std::fs::read_to_string(&scoped) else { + return false; + }; + let Some(patched) = apply_unified_diff(&original, patch) else { + return false; + }; + std::fs::write(scoped, patched).is_ok() + } + FileAction::Delete => { + let Some(scoped) = scoped_path(base_dir, path) else { + return false; + }; + if scoped.exists() { + std::fs::remove_file(scoped).is_ok() + } else { + false + } + } + FileAction::Rename => { + let Some(target) = target_path else { + return false; + }; + let Some(source_scoped) = scoped_path(base_dir, path) else { + return false; + }; + let Some(target_scoped) = scoped_path(base_dir, target) else { + return false; + }; + if let Some(parent) = target_scoped.parent() { + if std::fs::create_dir_all(parent).is_err() { + return false; + } + } + std::fs::rename(source_scoped, target_scoped).is_ok() + } + FileAction::Read => false, + } +} + +fn apply_unified_diff(original: &str, diff: &str) -> Option { + let mut output: Vec = Vec::new(); + let had_trailing_newline = original.ends_with('\n'); + let mut source: Vec<&str> = original.lines().collect(); + let mut index = 0usize; + let mut saw_hunk = false; + let mut lines = diff.lines().peekable(); + + while let Some(line) = lines.next() { + if !line.starts_with("@@") { + continue; + } + let old_start = parse_hunk_start(line)?; + saw_hunk = true; + let old_index = old_start.saturating_sub(1); + if old_index > source.len() { + return None; + } + for item in &source[index..old_index] { + output.push((*item).to_string()); + } + index = old_index; + while let Some(next) = lines.peek() { + if next.starts_with("@@") { + break; + } + let hunk_line = lines.next().unwrap(); + if hunk_line.starts_with(' ') { + if index >= source.len() { + return None; + } + output.push(hunk_line[1..].to_string()); + index += 1; + } else if hunk_line.starts_with('-') { + if index >= source.len() { + return None; + } + index += 1; + } else if hunk_line.starts_with('+') { + output.push(hunk_line[1..].to_string()); + } else if hunk_line.starts_with("\\") { + continue; + } else { + return None; + } + } + } + + if !saw_hunk { + return None; + } + + for item in &source[index..] { + output.push((*item).to_string()); + } + + let mut result = output.join("\n"); + if had_trailing_newline { + result.push('\n'); + } + Some(result) +} + +fn parse_hunk_start(line: &str) -> Option { + let trimmed = line.trim(); + let trimmed = trimmed.strip_prefix("@@")?.trim(); + let mut parts = trimmed.split_whitespace(); + let old = parts.next()?; + let old = old.trim_start_matches('-'); + let start = old.split(',').next()?; + start.parse::().ok() +} + #[derive(Debug)] enum ManagedServerKind { Http { base_url: String }, @@ -812,12 +1274,18 @@ struct AgentServerManager { restart_notifier: Mutex>>, } + #[derive(Debug)] pub(crate) struct SessionManager { agent_manager: Arc, sessions: Mutex>, server_manager: Arc, http_client: Client, + formatter_service: Arc, + lsp_registry: Arc, + pty_manager: Arc, + search: SearchService, + mcp_registry: Mutex>, } /// Shared Codex app-server process that handles multiple sessions via JSON-RPC. @@ -937,6 +1405,160 @@ pub(crate) struct PendingQuestionInfo { pub options: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] +#[serde(tag = "type", rename_all = "lowercase")] +pub(crate) enum McpServerConfig { + Local { + command: Vec, + #[serde(default)] + environment: HashMap, + #[serde(default)] + enabled: Option, + #[serde(default)] + timeout: Option, + }, + Remote { + url: String, + #[serde(default)] + enabled: Option, + #[serde(default)] + headers: HashMap, + #[serde(default)] + oauth: Option, + #[serde(default)] + timeout: Option, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub(crate) struct McpOAuthConfig { + #[serde(default)] + pub client_id: Option, + #[serde(default)] + pub client_secret: Option, + #[serde(default)] + pub scope: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] +#[serde(untagged)] +pub(crate) enum McpOAuthConfigValue { + Config(McpOAuthConfig), + Disabled(bool), +} + +#[derive(Debug, Clone)] +pub(crate) struct McpTool { + pub server: String, + pub name: String, + pub description: String, + pub parameters: Value, +} + +impl McpTool { + pub(crate) fn id(&self) -> String { + format!("mcp:{}:{}", self.server, self.name) + } + + pub(crate) fn to_tool_list_item(&self) -> Value { + json!({ + "id": self.id(), + "description": self.description, + "parameters": self.parameters, + }) + } +} + +#[derive(Debug, Clone)] +pub(crate) enum McpStatus { + Connected, + Disabled, + Failed { error: String }, + NeedsAuth, + NeedsClientRegistration { error: String }, +} + +impl McpStatus { + pub(crate) fn to_value(&self) -> Value { + match self { + McpStatus::Connected => json!({"status": "connected"}), + McpStatus::Disabled => json!({"status": "disabled"}), + McpStatus::Failed { error } => json!({"status": "failed", "error": error}), + McpStatus::NeedsAuth => json!({"status": "needs_auth"}), + McpStatus::NeedsClientRegistration { error } => { + json!({"status": "needs_client_registration", "error": error}) + } + } + } +} + +#[derive(Debug, Clone)] +pub(crate) struct McpServerEntry { + pub name: String, + pub config: McpServerConfig, + pub status: McpStatus, + pub auth_token: Option, + pub tools: Vec, +} + +#[derive(Debug)] +pub(crate) enum McpRegistryError { + NotFound, + Invalid(String), + Transport(String), +} + +impl std::fmt::Display for McpRegistryError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + McpRegistryError::NotFound => write!(f, "MCP server not found"), + McpRegistryError::Invalid(message) => write!(f, "{message}"), + McpRegistryError::Transport(message) => write!(f, "{message}"), + } + } +} + +impl McpServerConfig { + fn enabled(&self) -> bool { + match self { + McpServerConfig::Local { enabled, .. } => enabled.unwrap_or(true), + McpServerConfig::Remote { enabled, .. } => enabled.unwrap_or(true), + } + } + + fn requires_auth(&self) -> bool { + match self { + McpServerConfig::Remote { oauth, .. } => match oauth { + Some(McpOAuthConfigValue::Config(_)) => true, + _ => false, + }, + McpServerConfig::Local { .. } => false, + } + } + + fn timeout_ms(&self) -> Option { + match self { + McpServerConfig::Local { timeout, .. } => *timeout, + McpServerConfig::Remote { timeout, .. } => *timeout, + } + } + + fn remote_url(&self) -> Option<&str> { + match self { + McpServerConfig::Remote { url, .. } => Some(url.as_str()), + _ => None, + } + } + + fn headers(&self) -> Option<&HashMap> { + match self { + McpServerConfig::Remote { headers, .. } => Some(headers), + _ => None, + } + } +} + impl ManagedServer { fn base_url(&self) -> Option { match &self.kind { @@ -1538,9 +2160,339 @@ impl SessionManager { sessions: Mutex::new(Vec::new()), server_manager, http_client: Client::new(), + formatter_service: Arc::new(FormatterService::new()), + lsp_registry: Arc::new(LspRegistry::new()), + pty_manager: Arc::new(PtyManager::new()), + search: SearchService::new(), + mcp_registry: Mutex::new(HashMap::new()), } } + pub(crate) fn pty_manager(&self) -> Arc { + self.pty_manager.clone() + } + + pub(crate) fn formatter_status(&self, directory: &str) -> Vec { + self.formatter_service.status_for_directory(directory) + } + + pub(crate) fn lsp_status(&self, directory: &str) -> Vec { + self.lsp_registry.status_for_directory(directory) + } + + pub(crate) async fn find_text( + &self, + directory: &str, + pattern: &str, + case_sensitive: Option, + limit: Option, + ) -> Result, SandboxError> { + let root = SearchService::resolve_directory(directory)?; + let options = FindTextOptions { + case_sensitive, + limit, + }; + self.search + .find_text(root, pattern.to_string(), Some(options)) + .await + } + + pub(crate) async fn find_files( + &self, + directory: &str, + query: &str, + options: FindFileOptions, + ) -> Result, SandboxError> { + let root = SearchService::resolve_directory(directory)?; + self.search.find_files(root, query.to_string(), options).await + } + + pub(crate) async fn find_symbols( + &self, + directory: &str, + query: &str, + limit: Option, + ) -> Result, SandboxError> { + let root = SearchService::resolve_directory(directory)?; + let options = FindSymbolOptions { limit }; + self.search + .find_symbols(root, query.to_string(), Some(options)) + .await + } + + pub(crate) async fn mcp_statuses(&self) -> HashMap { + let registry = self.mcp_registry.lock().await; + registry + .iter() + .map(|(name, entry)| (name.clone(), entry.status.clone())) + .collect() + } + + pub(crate) async fn mcp_status(&self, name: &str) -> Result { + let registry = self.mcp_registry.lock().await; + let entry = registry.get(name).ok_or(McpRegistryError::NotFound)?; + Ok(entry.status.clone()) + } + + pub(crate) async fn mcp_register( + &self, + name: String, + config: McpServerConfig, + ) -> Result, McpRegistryError> { + if name.trim().is_empty() { + return Err(McpRegistryError::Invalid("name is required".to_string())); + } + match &config { + McpServerConfig::Local { command, .. } if command.is_empty() => { + return Err(McpRegistryError::Invalid("command is required".to_string())); + } + McpServerConfig::Remote { url, .. } if url.trim().is_empty() => { + return Err(McpRegistryError::Invalid("url is required".to_string())); + } + _ => {} + } + let status = if !config.enabled() { + McpStatus::Disabled + } else if config.requires_auth() { + McpStatus::NeedsAuth + } else { + McpStatus::Disabled + }; + let entry = McpServerEntry { + name: name.clone(), + config, + status, + auth_token: None, + tools: Vec::new(), + }; + let mut registry = self.mcp_registry.lock().await; + registry.insert(name, entry); + Ok(registry + .iter() + .map(|(name, entry)| (name.clone(), entry.status.clone())) + .collect()) + } + + pub(crate) async fn mcp_auth_start(&self, name: &str) -> Result { + let mut registry = self.mcp_registry.lock().await; + let entry = registry.get_mut(name).ok_or(McpRegistryError::NotFound)?; + if !entry.config.requires_auth() { + return Err(McpRegistryError::Invalid( + "MCP server does not require auth".to_string(), + )); + } + entry.status = McpStatus::NeedsAuth; + Ok(format!("https://auth.local/mcp/{name}/authorize")) + } + + pub(crate) async fn mcp_auth_remove(&self, name: &str) -> Result { + let mut registry = self.mcp_registry.lock().await; + let entry = registry.get_mut(name).ok_or(McpRegistryError::NotFound)?; + entry.auth_token = None; + entry.tools.clear(); + entry.status = if entry.config.requires_auth() { + McpStatus::NeedsAuth + } else { + McpStatus::Disabled + }; + Ok(true) + } + + pub(crate) async fn mcp_auth_callback( + &self, + name: &str, + code: String, + ) -> Result { + if code.trim().is_empty() { + return Err(McpRegistryError::Invalid("code is required".to_string())); + } + { + let mut registry = self.mcp_registry.lock().await; + let entry = registry.get_mut(name).ok_or(McpRegistryError::NotFound)?; + if !entry.config.requires_auth() { + return Err(McpRegistryError::Invalid( + "MCP server does not require auth".to_string(), + )); + } + entry.auth_token = Some(code); + entry.status = McpStatus::Disabled; + } + match self.mcp_connect(name).await { + Ok(_) => {} + Err(McpRegistryError::NotFound) => return Err(McpRegistryError::NotFound), + Err(_) => {} + } + self.mcp_status(name).await + } + + pub(crate) async fn mcp_auth_authenticate( + &self, + name: &str, + ) -> Result { + let mut registry = self.mcp_registry.lock().await; + let entry = registry.get_mut(name).ok_or(McpRegistryError::NotFound)?; + if entry.auth_token.is_some() { + return Ok(entry.status.clone()); + } + if entry.config.requires_auth() { + entry.status = McpStatus::NeedsAuth; + return Ok(entry.status.clone()); + } + Ok(entry.status.clone()) + } + + pub(crate) async fn mcp_connect(&self, name: &str) -> Result { + let (config, auth_token) = { + let registry = self.mcp_registry.lock().await; + let entry = registry.get(name).ok_or(McpRegistryError::NotFound)?; + if !entry.config.enabled() { + return Err(McpRegistryError::Invalid("MCP server is disabled".to_string())); + } + if entry.config.requires_auth() && entry.auth_token.is_none() { + return Err(McpRegistryError::Invalid( + "MCP server requires auth".to_string(), + )); + } + (entry.config.clone(), entry.auth_token.clone()) + }; + + let tools_result = self + .fetch_mcp_tools(name, &config, auth_token.as_deref()) + .await; + let mut registry = self.mcp_registry.lock().await; + let entry = registry.get_mut(name).ok_or(McpRegistryError::NotFound)?; + match tools_result { + Ok(tools) => { + entry.tools = tools; + entry.status = McpStatus::Connected; + Ok(true) + } + Err(err) => { + entry.tools.clear(); + entry.status = McpStatus::Failed { + error: err.to_string(), + }; + Err(err) + } + } + } + + pub(crate) async fn mcp_disconnect(&self, name: &str) -> Result { + let mut registry = self.mcp_registry.lock().await; + let entry = registry.get_mut(name).ok_or(McpRegistryError::NotFound)?; + entry.tools.clear(); + entry.status = McpStatus::Disabled; + Ok(true) + } + + pub(crate) async fn mcp_tools(&self) -> Vec { + let registry = self.mcp_registry.lock().await; + registry + .values() + .filter(|entry| matches!(entry.status, McpStatus::Connected)) + .flat_map(|entry| entry.tools.clone()) + .collect() + } + + pub(crate) async fn mcp_tool_ids(&self) -> Vec { + self.mcp_tools() + .await + .into_iter() + .map(|tool| tool.id()) + .collect() + } + + fn build_mcp_headers( + headers: &HashMap, + ) -> Result { + let mut map = ReqwestHeaderMap::new(); + for (key, value) in headers { + let name = ReqwestHeaderName::from_bytes(key.as_bytes()).map_err(|_| { + McpRegistryError::Invalid(format!("invalid header name: {key}")) + })?; + let value = ReqwestHeaderValue::from_str(value).map_err(|_| { + McpRegistryError::Invalid(format!("invalid header value for {key}")) + })?; + map.insert(name, value); + } + Ok(map) + } + + async fn fetch_mcp_tools( + &self, + name: &str, + config: &McpServerConfig, + auth_token: Option<&str>, + ) -> Result, McpRegistryError> { + let url = config.remote_url().ok_or_else(|| { + McpRegistryError::Invalid("local MCP servers are not supported".to_string()) + })?; + let request_payload = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/list", + "params": {} + }); + let mut request = self.http_client.post(url).json(&request_payload); + if let Some(timeout) = config.timeout_ms() { + request = request.timeout(Duration::from_millis(timeout)); + } + if let Some(headers) = config.headers() { + if !headers.is_empty() { + let header_map = Self::build_mcp_headers(headers)?; + request = request.headers(header_map); + } + } + if let Some(token) = auth_token { + request = request.bearer_auth(token); + } + let response = request + .send() + .await + .map_err(|err| McpRegistryError::Transport(format!("mcp request failed: {err}")))?; + if !response.status().is_success() { + return Err(McpRegistryError::Transport(format!( + "mcp request failed with status {}", + response.status() + ))); + } + let value: Value = response.json().await.map_err(|err| { + McpRegistryError::Transport(format!("mcp response parse failed: {err}")) + })?; + let tools_value = value + .get("result") + .and_then(|v| v.get("tools")) + .and_then(|v| v.as_array()) + .ok_or_else(|| { + McpRegistryError::Transport("mcp tools response missing tools".to_string()) + })?; + let mut tools = Vec::new(); + for tool_value in tools_value { + let name_value = tool_value + .get("name") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + McpRegistryError::Transport("mcp tool missing name".to_string()) + })?; + let description = tool_value + .get("description") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let parameters = tool_value + .get("inputSchema") + .cloned() + .unwrap_or_else(|| json!({})); + tools.push(McpTool { + server: name.to_string(), + name: name_value.to_string(), + description, + parameters, + }); + } + Ok(tools) + } + fn session_ref<'a>(sessions: &'a [SessionState], session_id: &str) -> Option<&'a SessionState> { sessions .iter() @@ -1840,6 +2792,7 @@ impl SessionManager { .unregister_session(agent, &session_id, native_session_id.as_deref()) .await; } + self.pty_manager.cleanup_session(&session_id).await; Ok(()) } @@ -1960,6 +2913,28 @@ impl SessionManager { items } + pub(crate) async fn tool_call_snapshot( + &self, + session_id: &str, + call_id: &str, + ) -> Option { + let sessions = self.sessions.lock().await; + let session = Self::session_ref(&sessions, session_id)?; + session.tool_call_snapshot(call_id) + } + + pub(crate) async fn file_actions_for_event( + &self, + session_id: &str, + sequence: u64, + ) -> Vec { + let sessions = self.sessions.lock().await; + let Some(session) = Self::session_ref(&sessions, session_id) else { + return Vec::new(); + }; + session.file_actions_for_event(sequence) + } + async fn subscribe_for_turn( &self, session_id: &str, @@ -6024,6 +6999,10 @@ fn mock_tool_sequence(prefix: &str) -> Vec { vec![tool_call_part.clone()], ), )); + events.push(mock_delta( + tool_call_native.clone(), + "mock tool call progress", + )); events.push(mock_item_event( UniversalEventType::ItemCompleted, mock_item( @@ -6044,16 +7023,49 @@ fn mock_tool_sequence(prefix: &str) -> Vec { path: format!("{prefix}/readme.md"), action: FileAction::Read, diff: None, + target_path: None, }, ContentPart::FileRef { path: format!("{prefix}/output.txt"), action: FileAction::Write, - diff: Some("+mock output\n".to_string()), + diff: Some("mock output\n".to_string()), + target_path: None, + }, + ContentPart::FileRef { + path: format!("{prefix}/patch.txt"), + action: FileAction::Write, + diff: Some("old\n".to_string()), + target_path: None, }, ContentPart::FileRef { path: format!("{prefix}/patch.txt"), action: FileAction::Patch, diff: Some("@@ -1,1 +1,1 @@\n-old\n+new\n".to_string()), + target_path: None, + }, + ContentPart::FileRef { + path: format!("{prefix}/rename.txt"), + action: FileAction::Write, + diff: Some("rename me\n".to_string()), + target_path: None, + }, + ContentPart::FileRef { + path: format!("{prefix}/rename.txt"), + action: FileAction::Rename, + diff: None, + target_path: Some(format!("{prefix}/renamed.txt")), + }, + ContentPart::FileRef { + path: format!("{prefix}/delete.txt"), + action: FileAction::Write, + diff: Some("delete me\n".to_string()), + target_path: None, + }, + ContentPart::FileRef { + path: format!("{prefix}/delete.txt"), + action: FileAction::Delete, + diff: None, + target_path: None, }, ]; events.push(mock_item_event( @@ -6072,7 +7084,7 @@ fn mock_tool_sequence(prefix: &str) -> Vec { tool_result_native, ItemKind::ToolResult, ItemRole::Tool, - ItemStatus::Failed, + ItemStatus::Completed, tool_result_parts, ), )); diff --git a/server/packages/sandbox-agent/src/search.rs b/server/packages/sandbox-agent/src/search.rs new file mode 100644 index 0000000..13790b0 --- /dev/null +++ b/server/packages/sandbox-agent/src/search.rs @@ -0,0 +1,714 @@ +use std::collections::HashMap; +use std::fs; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::SystemTime; + +use regress::Regex; +use sandbox_agent_error::SandboxError; +use serde::{Deserialize, Serialize}; +use tokio::process::Command; + +const DEFAULT_LIMIT: usize = 200; +const MAX_LIMIT: usize = 1_000; +const MAX_FILES: usize = 50_000; +const MAX_FILE_BYTES: u64 = 1_000_000; +const IGNORED_DIRS: [&str; 10] = [ + ".git", + "node_modules", + "target", + "dist", + "build", + ".next", + ".cache", + ".turbo", + ".sandbox-agent", + "coverage", +]; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FindFileKind { + File, + Directory, + Any, +} + +impl Default for FindFileKind { + fn default() -> Self { + Self::File + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FindFileOptions { + #[serde(default)] + pub kind: FindFileKind, + #[serde(default)] + pub case_sensitive: bool, + #[serde(default)] + pub limit: Option, +} + +impl Default for FindFileOptions { + fn default() -> Self { + Self { + kind: FindFileKind::default(), + case_sensitive: false, + limit: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TextField { + pub text: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FindTextMatch { + pub path: TextField, + pub lines: TextField, + pub line_number: u64, + pub absolute_offset: u64, + pub submatches: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FindTextSubmatch { + #[serde(rename = "match")] + pub match_field: TextField, + pub start: u64, + pub end: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SymbolResult { + pub name: String, + pub kind: String, + pub path: String, + pub line: usize, + pub column: usize, +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct FindTextOptions { + pub case_sensitive: Option, + pub limit: Option, +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct FindSymbolOptions { + pub limit: Option, +} + +#[derive(Debug, Clone)] +struct FileSymbols { + modified: SystemTime, + symbols: Vec, +} + +#[derive(Debug, Default, Clone)] +struct SymbolIndex { + files: HashMap, +} + +#[derive(Debug, Clone)] +struct SymbolPattern { + regex: Regex, + kind: &'static str, +} + +#[derive(Debug, Clone)] +pub struct SearchService { + symbol_cache: Arc>>, + symbol_patterns: Arc>, +} + +impl SearchService { + pub fn new() -> Self { + let patterns = vec![ + SymbolPattern { + regex: Regex::new(r"^\s*(?:pub\s+)?(?:async\s+)?fn\s+([A-Za-z_][A-Za-z0-9_]*)") + .expect("valid fn regex"), + kind: "function", + }, + SymbolPattern { + regex: Regex::new(r"^\s*(?:def)\s+([A-Za-z_][A-Za-z0-9_]*)") + .expect("valid def regex"), + kind: "function", + }, + SymbolPattern { + regex: Regex::new(r"^\s*(?:export\s+)?function\s+([A-Za-z_][A-Za-z0-9_]*)") + .expect("valid function regex"), + kind: "function", + }, + SymbolPattern { + regex: Regex::new(r"^\s*(?:pub\s+)?struct\s+([A-Za-z_][A-Za-z0-9_]*)") + .expect("valid struct regex"), + kind: "struct", + }, + SymbolPattern { + regex: Regex::new(r"^\s*(?:pub\s+)?enum\s+([A-Za-z_][A-Za-z0-9_]*)") + .expect("valid enum regex"), + kind: "enum", + }, + SymbolPattern { + regex: Regex::new(r"^\s*(?:class)\s+([A-Za-z_][A-Za-z0-9_]*)") + .expect("valid class regex"), + kind: "class", + }, + SymbolPattern { + regex: Regex::new(r"^\s*(?:interface)\s+([A-Za-z_][A-Za-z0-9_]*)") + .expect("valid interface regex"), + kind: "interface", + }, + SymbolPattern { + regex: Regex::new(r"^\s*(?:trait)\s+([A-Za-z_][A-Za-z0-9_]*)") + .expect("valid trait regex"), + kind: "trait", + }, + ]; + Self { + symbol_cache: Arc::new(Mutex::new(HashMap::new())), + symbol_patterns: Arc::new(patterns), + } + } + + pub fn resolve_directory(directory: &str) -> Result { + let trimmed = directory.trim(); + if trimmed.is_empty() { + return Err(SandboxError::InvalidRequest { + message: "directory is required".to_string(), + }); + } + let root = PathBuf::from(trimmed); + if !root.exists() { + return Err(SandboxError::InvalidRequest { + message: "directory does not exist".to_string(), + }); + } + let canonical = root.canonicalize().map_err(|_| SandboxError::InvalidRequest { + message: "directory could not be resolved".to_string(), + })?; + if !canonical.is_dir() { + return Err(SandboxError::InvalidRequest { + message: "directory is not a folder".to_string(), + }); + } + Ok(canonical) + } + + pub async fn find_text( + &self, + root: PathBuf, + pattern: String, + options: Option, + ) -> Result, SandboxError> { + let options = options.unwrap_or_default(); + let limit = resolve_limit(options.limit); + if limit == 0 { + return Ok(Vec::new()); + } + match rg_text_matches(&root, &pattern, limit, options.case_sensitive).await { + Ok(matches) => Ok(matches), + Err(RgError::NotAvailable) => { + let service = self.clone(); + let pattern = pattern.clone(); + tokio::task::spawn_blocking(move || { + service.find_text_fallback(&root, &pattern, limit, options.case_sensitive) + }) + .await + .unwrap_or_else(|_| { + Err(SandboxError::StreamError { + message: "search failed".to_string(), + }) + }) + } + Err(RgError::InvalidPattern(message)) => Err(SandboxError::InvalidRequest { message }), + Err(RgError::Failed(message)) => Err(SandboxError::StreamError { message }), + } + } + + pub async fn find_files( + &self, + root: PathBuf, + query: String, + options: FindFileOptions, + ) -> Result, SandboxError> { + let limit = resolve_limit(options.limit); + if limit == 0 { + return Ok(Vec::new()); + } + let service = self.clone(); + tokio::task::spawn_blocking(move || service.find_files_blocking(&root, &query, options, limit)) + .await + .unwrap_or_else(|_| { + Err(SandboxError::StreamError { + message: "search failed".to_string(), + }) + }) + } + + pub async fn find_symbols( + &self, + root: PathBuf, + query: String, + options: Option, + ) -> Result, SandboxError> { + let options = options.unwrap_or_default(); + let limit = resolve_limit(options.limit); + if limit == 0 { + return Ok(Vec::new()); + } + let service = self.clone(); + tokio::task::spawn_blocking(move || service.find_symbols_blocking(&root, &query, limit)) + .await + .unwrap_or_else(|_| { + Err(SandboxError::StreamError { + message: "search failed".to_string(), + }) + }) + } + + fn find_text_fallback( + &self, + root: &Path, + pattern: &str, + limit: usize, + case_sensitive: Option, + ) -> Result, SandboxError> { + let regex = if case_sensitive == Some(false) { + Regex::with_flags(pattern, "i") + } else { + Regex::new(pattern) + } + .map_err(|err| SandboxError::InvalidRequest { + message: format!("invalid pattern: {err}"), + })?; + + let files = collect_files(root, MAX_FILES, FindFileKind::File); + let mut results = Vec::new(); + for file in files { + if results.len() >= limit { + break; + } + let metadata = match fs::metadata(&file) { + Ok(metadata) => metadata, + Err(_) => continue, + }; + if metadata.len() > MAX_FILE_BYTES { + continue; + } + let Ok(handle) = fs::File::open(&file) else { + continue; + }; + let reader = BufReader::new(handle); + let mut absolute_offset = 0u64; + for (index, line) in reader.lines().enumerate() { + if results.len() >= limit { + break; + } + let Ok(line) = line else { + continue; + }; + let line_start = absolute_offset; + absolute_offset = absolute_offset.saturating_add(line.as_bytes().len() as u64 + 1); + for matched in regex.find_iter(&line) { + let relative = match file.strip_prefix(root) { + Ok(path) => path, + Err(_) => continue, + }; + let start = matched.range().start as u64; + let end = matched.range().end as u64; + results.push(FindTextMatch { + path: TextField { + text: normalize_path(relative), + }, + lines: TextField { + text: line.clone(), + }, + line_number: (index + 1) as u64, + absolute_offset: line_start.saturating_add(start), + submatches: vec![FindTextSubmatch { + match_field: TextField { + text: matched.as_str().to_string(), + }, + start, + end, + }], + }); + if results.len() >= limit { + break; + } + } + } + } + Ok(results) + } + + fn find_files_blocking( + &self, + root: &Path, + query: &str, + options: FindFileOptions, + limit: usize, + ) -> Result, SandboxError> { + let files = collect_files(root, MAX_FILES, options.kind); + let mut results = Vec::new(); + for path in files { + if results.len() >= limit { + break; + } + let relative = match path.strip_prefix(root) { + Ok(path) => path, + Err(_) => continue, + }; + let path_text = normalize_path(relative); + if path_text.is_empty() { + continue; + } + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(""); + let file_name = normalize_query(file_name, options.case_sensitive); + let candidate = normalize_query(&path_text, options.case_sensitive); + if matches_query(&candidate, query, options.case_sensitive) + || matches_query(&file_name, query, options.case_sensitive) + { + results.push(path_text); + } + } + Ok(results) + } + + fn find_symbols_blocking( + &self, + root: &Path, + query: &str, + limit: usize, + ) -> Result, SandboxError> { + let files = collect_files(root, MAX_FILES, FindFileKind::File); + let mut cache = self + .symbol_cache + .lock() + .expect("symbol cache lock"); + let index = cache.entry(root.to_path_buf()).or_default(); + + for file in &files { + let metadata = match fs::metadata(file) { + Ok(metadata) => metadata, + Err(_) => continue, + }; + if metadata.len() > MAX_FILE_BYTES { + continue; + } + let modified = match metadata.modified() { + Ok(time) => time, + Err(_) => continue, + }; + let needs_refresh = index + .files + .get(file) + .map(|entry| entry.modified != modified) + .unwrap_or(true); + if !needs_refresh { + continue; + } + let symbols = extract_symbols(file, root, &self.symbol_patterns); + index.files.insert( + file.clone(), + FileSymbols { + modified, + symbols, + }, + ); + } + + let mut results = Vec::new(); + let query = normalize_query(query, false); + for entry in index.files.values() { + for symbol in &entry.symbols { + if results.len() >= limit { + break; + } + if normalize_query(&symbol.name, false).contains(&query) { + results.push(symbol.clone()); + } + } + if results.len() >= limit { + break; + } + } + Ok(results) + } +} + +#[derive(Debug)] +enum RgError { + NotAvailable, + InvalidPattern(String), + Failed(String), +} + +async fn rg_text_matches( + root: &Path, + pattern: &str, + limit: usize, + case_sensitive: Option, +) -> Result, RgError> { + let mut command = Command::new("rg"); + command + .arg("--json") + .arg("--line-number") + .arg("--byte-offset") + .arg("--with-filename") + .arg("--max-count") + .arg(limit.to_string()); + if case_sensitive == Some(false) { + command.arg("--ignore-case"); + } + command.arg(pattern); + command.current_dir(root); + + let output = match command.output().await { + Ok(output) => output, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Err(RgError::NotAvailable), + Err(err) => return Err(RgError::Failed(err.to_string())), + }; + if !output.status.success() { + if output.status.code() == Some(1) { + return Ok(Vec::new()); + } + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + if stderr.contains("regex parse error") || stderr.contains("error parsing") { + return Err(RgError::InvalidPattern(stderr.trim().to_string())); + } + return Err(RgError::Failed("ripgrep failed".to_string())); + } + + let mut results = Vec::new(); + for line in output.stdout.split(|b| *b == b'\n') { + if line.is_empty() { + continue; + } + let Ok(value) = serde_json::from_slice::(line) else { + continue; + }; + if value.get("type").and_then(|v| v.as_str()) != Some("match") { + continue; + } + let Some(data) = value.get("data") else { + continue; + }; + if let Some(entry) = match_from_rg_data(root, data) { + results.push(entry); + if results.len() >= limit { + break; + } + } + } + + Ok(results) +} + +fn match_from_rg_data(root: &Path, data: &serde_json::Value) -> Option { + let path_text = data + .get("path") + .and_then(|v| v.get("text")) + .and_then(|v| v.as_str())?; + let line_number = data.get("line_number").and_then(|v| v.as_u64())?; + let absolute_offset = data.get("absolute_offset").and_then(|v| v.as_u64())?; + let line_text = data + .get("lines") + .and_then(|v| v.get("text")) + .and_then(|v| v.as_str())? + .trim_end_matches('\n') + .to_string(); + let submatches = data + .get("submatches") + .and_then(|v| v.as_array()) + .map(|matches| { + matches + .iter() + .filter_map(|submatch| { + let match_text = submatch + .get("match") + .and_then(|v| v.get("text")) + .and_then(|v| v.as_str())?; + let start = submatch.get("start").and_then(|v| v.as_u64())?; + let end = submatch.get("end").and_then(|v| v.as_u64())?; + Some(FindTextSubmatch { + match_field: TextField { + text: match_text.to_string(), + }, + start, + end, + }) + }) + .collect::>() + }) + .unwrap_or_default(); + + let path = Path::new(path_text); + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + root.join(path) + }; + let relative = absolute.strip_prefix(root).unwrap_or(&absolute); + + Some(FindTextMatch { + path: TextField { + text: normalize_path(relative), + }, + lines: TextField { text: line_text }, + line_number, + absolute_offset, + submatches, + }) +} + +fn resolve_limit(limit: Option) -> usize { + limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) +} + +fn normalize_query(value: &str, case_sensitive: bool) -> String { + if case_sensitive { + value.to_string() + } else { + value.to_lowercase() + } +} + +fn normalize_path(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} + +fn is_ignored_dir(path: &Path) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .map(|name| IGNORED_DIRS.contains(&name)) + .unwrap_or(false) +} + +fn collect_files(root: &Path, max_files: usize, kind: FindFileKind) -> Vec { + let mut entries = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + let entries_iter = match fs::read_dir(&dir) { + Ok(entries_iter) => entries_iter, + Err(_) => continue, + }; + for entry in entries_iter.flatten() { + if entries.len() >= max_files { + return entries; + } + let Ok(file_type) = entry.file_type() else { + continue; + }; + if file_type.is_symlink() { + continue; + } + let path = entry.path(); + if file_type.is_dir() { + if is_ignored_dir(&path) { + continue; + } + if matches!(kind, FindFileKind::Directory | FindFileKind::Any) { + entries.push(path.clone()); + } + stack.push(path); + } else if file_type.is_file() { + if matches!(kind, FindFileKind::File | FindFileKind::Any) { + entries.push(path); + } + } + } + } + entries +} + +fn matches_query(candidate: &str, query: &str, case_sensitive: bool) -> bool { + let candidate = normalize_query(candidate, case_sensitive); + let query = normalize_query(query, case_sensitive); + if query.contains('*') || query.contains('?') { + return glob_match(&query, &candidate); + } + candidate.contains(&query) +} + +fn glob_match(pattern: &str, text: &str) -> bool { + glob_match_inner(pattern.as_bytes(), text.as_bytes()) +} + +fn glob_match_inner(pattern: &[u8], text: &[u8]) -> bool { + if pattern.is_empty() { + return text.is_empty(); + } + match pattern[0] { + b'*' => { + if glob_match_inner(&pattern[1..], text) { + return true; + } + if !text.is_empty() { + return glob_match_inner(pattern, &text[1..]); + } + false + } + b'?' => { + if text.is_empty() { + false + } else { + glob_match_inner(&pattern[1..], &text[1..]) + } + } + ch => { + if text.first().copied() == Some(ch) { + glob_match_inner(&pattern[1..], &text[1..]) + } else { + false + } + } + } +} + +fn extract_symbols(path: &Path, root: &Path, patterns: &[SymbolPattern]) -> Vec { + let Ok(file) = fs::File::open(path) else { + return Vec::new(); + }; + let reader = BufReader::new(file); + let mut symbols = Vec::new(); + for (index, line) in reader.lines().enumerate() { + let Ok(line) = line else { + continue; + }; + for pattern in patterns { + for matched in pattern.regex.find_iter(&line) { + let Some(group) = matched.group(1) else { + continue; + }; + let name = line.get(group.clone()).unwrap_or("").to_string(); + if name.is_empty() { + continue; + } + let relative = match path.strip_prefix(root) { + Ok(path) => path, + Err(_) => continue, + }; + symbols.push(SymbolResult { + name, + kind: pattern.kind.to_string(), + path: normalize_path(relative), + line: index + 1, + column: group.start + 1, + }); + } + } + } + symbols +} diff --git a/server/packages/sandbox-agent/tests/http/opencode_formatter_lsp.rs b/server/packages/sandbox-agent/tests/http/opencode_formatter_lsp.rs new file mode 100644 index 0000000..6ea1fc1 --- /dev/null +++ b/server/packages/sandbox-agent/tests/http/opencode_formatter_lsp.rs @@ -0,0 +1,80 @@ +include!("../common/http.rs"); + +fn expect_formatter(payload: &serde_json::Value, name: &str, ext: &str) { + let entries = payload + .as_array() + .unwrap_or_else(|| panic!("formatter payload should be array: {payload}")); + let entry = entries + .iter() + .find(|value| value.get("name").and_then(|v| v.as_str()) == Some(name)) + .unwrap_or_else(|| panic!("formatter {name} not found in {entries:?}")); + let enabled = entry.get("enabled").and_then(|value| value.as_bool()); + assert_eq!(enabled, Some(true), "formatter {name} should be enabled"); + let extensions = entry + .get("extensions") + .and_then(|value| value.as_array()) + .unwrap_or_else(|| panic!("formatter {name} extensions missing: {entry}")); + let has_ext = extensions + .iter() + .any(|value| value.as_str() == Some(ext)); + assert!(has_ext, "formatter {name} missing extension {ext}"); +} + +fn expect_lsp(payload: &serde_json::Value, id: &str, root: &str) { + let entries = payload + .as_array() + .unwrap_or_else(|| panic!("lsp payload should be array: {payload}")); + let entry = entries + .iter() + .find(|value| value.get("id").and_then(|v| v.as_str()) == Some(id)) + .unwrap_or_else(|| panic!("lsp {id} not found in {entries:?}")); + let entry_root = entry + .get("root") + .and_then(|value| value.as_str()) + .unwrap_or(""); + assert_eq!(entry_root, root, "lsp {id} root mismatch"); + let status = entry + .get("status") + .and_then(|value| value.as_str()) + .unwrap_or(""); + assert!( + matches!(status, "connected" | "error"), + "lsp {id} status unexpected: {status}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn opencode_formatter_and_lsp_status() { + let app = TestApp::new(); + let tempdir = tempfile::tempdir().expect("tempdir"); + let root = tempdir.path(); + std::fs::create_dir_all(root.join("src")).expect("create src"); + std::fs::write(root.join("src/main.rs"), "fn main() {}\n").expect("write rs"); + std::fs::write(root.join("src/app.ts"), "export const x = 1;\n").expect("write ts"); + + let root_str = root + .to_str() + .unwrap_or_else(|| panic!("tempdir path not utf8: {root:?}")); + + let formatter_request = Request::builder() + .method(Method::GET) + .uri("/opencode/formatter") + .header("x-opencode-directory", root_str) + .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"); + expect_formatter(&payload, "rustfmt", ".rs"); + expect_formatter(&payload, "prettier", ".ts"); + + let lsp_request = Request::builder() + .method(Method::GET) + .uri("/opencode/lsp") + .header("x-opencode-directory", root_str) + .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"); + expect_lsp(&payload, "rust-analyzer", root_str); + expect_lsp(&payload, "typescript-language-server", root_str); +} diff --git a/server/packages/sandbox-agent/tests/http_endpoints.rs b/server/packages/sandbox-agent/tests/http_endpoints.rs index a443a95..26a681b 100644 --- a/server/packages/sandbox-agent/tests/http_endpoints.rs +++ b/server/packages/sandbox-agent/tests/http_endpoints.rs @@ -1,2 +1,3 @@ #[path = "http/agent_endpoints.rs"] mod agent_endpoints; +mod opencode_formatter_lsp; diff --git a/server/packages/sandbox-agent/tests/opencode-compat/events.test.ts b/server/packages/sandbox-agent/tests/opencode-compat/events.test.ts index 587ebf3..50943e5 100644 --- a/server/packages/sandbox-agent/tests/opencode-compat/events.test.ts +++ b/server/packages/sandbox-agent/tests/opencode-compat/events.test.ts @@ -13,6 +13,88 @@ 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"; +type SseEvent = { + id?: string; + data: any; +}; + +function parseSseMessage(raw: string): SseEvent | null { + const lines = raw.replace(/\r\n/g, "\n").split("\n"); + const dataLines: string[] = []; + let id: string | undefined; + + for (const line of lines) { + if (!line || line.startsWith(":")) { + continue; + } + if (line.startsWith("id:")) { + id = line.slice(3).trim(); + continue; + } + if (line.startsWith("data:")) { + dataLines.push(line.slice(5).trimStart()); + } + } + + if (dataLines.length === 0) { + return null; + } + + const dataText = dataLines.join("\n"); + try { + return { id, data: JSON.parse(dataText) }; + } catch { + return null; + } +} + +async function collectSseEvents( + url: string, + options: { headers: Record; limit: number; timeoutMs: number } +): Promise { + const { headers, limit, timeoutMs } = options; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + const response = await fetch(url, { headers, signal: controller.signal }); + expect(response.ok).toBe(true); + if (!response.body) { + clearTimeout(timeout); + throw new Error("SSE response missing body"); + } + + const decoder = new TextDecoder(); + let buffer = ""; + const events: SseEvent[] = []; + + try { + for await (const chunk of response.body as any) { + buffer += decoder.decode(chunk, { stream: true }); + let boundary = buffer.indexOf("\n\n"); + while (boundary >= 0) { + const raw = buffer.slice(0, boundary); + buffer = buffer.slice(boundary + 2); + const parsed = parseSseMessage(raw); + if (parsed) { + events.push(parsed); + if (events.length >= limit) { + controller.abort(); + clearTimeout(timeout); + return events; + } + } + boundary = buffer.indexOf("\n\n"); + } + } + } catch (error) { + if (!controller.signal.aborted) { + throw error; + } + } + + clearTimeout(timeout); + return events; +} + describe("OpenCode-compatible Event Streaming", () => { let handle: SandboxAgentHandle; let client: OpencodeClient; @@ -125,6 +207,81 @@ describe("OpenCode-compatible Event Streaming", () => { // Should have received some events expect(events.length).toBeGreaterThan(0); }); + + it("should replay ordered events by offset", async () => { + const headers = { Authorization: `Bearer ${handle.token}` }; + const eventUrl = `${handle.baseUrl}/opencode/event?offset=0`; + + const initialEventsPromise = collectSseEvents(eventUrl, { + headers, + limit: 10, + timeoutMs: 10000, + }); + + const session = await client.session.create(); + const sessionId = session.data?.id!; + await client.session.prompt({ + path: { id: sessionId }, + body: { + model: { providerID: "sandbox-agent", modelID: "mock" }, + parts: [{ type: "text", text: "Say hello" }], + }, + }); + + const initialEvents = await initialEventsPromise; + const filteredInitial = initialEvents.filter( + (event) => event.data?.type && event.data.type !== "server.heartbeat" + ); + expect(filteredInitial.length).toBeGreaterThan(0); + + const ids = filteredInitial + .map((event) => Number(event.id)) + .filter((value) => Number.isFinite(value)); + expect(ids.length).toBeGreaterThan(0); + for (let i = 1; i < ids.length; i += 1) { + expect(ids[i]).toBeGreaterThan(ids[i - 1]); + } + + const types = new Set(filteredInitial.map((event) => event.data.type)); + expect(types.has("session.status")).toBe(true); + expect(types.has("message.updated")).toBe(true); + expect(types.has("message.part.updated")).toBe(true); + + const partEvent = filteredInitial.find( + (event) => event.data.type === "message.part.updated" + ); + expect(partEvent?.data?.properties?.part).toBeDefined(); + + const lastId = Math.max(...ids); + const followupSession = await client.session.create(); + const followupId = followupSession.data?.id!; + await client.session.prompt({ + path: { id: followupId }, + body: { + model: { providerID: "sandbox-agent", modelID: "mock" }, + parts: [{ type: "text", text: "Say hi again" }], + }, + }); + + const replayEvents = await collectSseEvents( + `${handle.baseUrl}/opencode/event?offset=${lastId}`, + { + headers, + limit: 8, + timeoutMs: 10000, + } + ); + const filteredReplay = replayEvents.filter( + (event) => event.data?.type && event.data.type !== "server.heartbeat" + ); + expect(filteredReplay.length).toBeGreaterThan(0); + + const replayIds = filteredReplay + .map((event) => Number(event.id)) + .filter((value) => Number.isFinite(value)); + expect(replayIds.length).toBeGreaterThan(0); + expect(Math.min(...replayIds)).toBeGreaterThan(lastId); + }); }); describe("global.event", () => { diff --git a/server/packages/sandbox-agent/tests/opencode-compat/find.test.ts b/server/packages/sandbox-agent/tests/opencode-compat/find.test.ts new file mode 100644 index 0000000..4400a72 --- /dev/null +++ b/server/packages/sandbox-agent/tests/opencode-compat/find.test.ts @@ -0,0 +1,60 @@ +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"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const fixtureRoot = resolve(__dirname, "fixtures/search-repo"); + +describe("OpenCode-compatible Find API", () => { + let handle: SandboxAgentHandle; + let client: OpencodeClient; + + beforeAll(async () => { + await buildSandboxAgent(); + }); + + beforeEach(async () => { + handle = await spawnSandboxAgent({ + opencodeCompat: true, + env: { + OPENCODE_COMPAT_DIRECTORY: fixtureRoot, + OPENCODE_COMPAT_WORKTREE: fixtureRoot, + }, + }); + client = createOpencodeClient({ + baseUrl: `${handle.baseUrl}/opencode`, + headers: { Authorization: `Bearer ${handle.token}` }, + }); + }); + + afterEach(async () => { + await handle?.dispose(); + }); + + it("should find matching text", async () => { + const response = await client.find.text({ + query: { directory: fixtureRoot, pattern: "Needle" }, + }); + const results = (response as any).data ?? []; + expect(results.length).toBeGreaterThan(0); + expect(results.some((match: any) => match.path?.text?.includes("README.md"))).toBe(true); + }); + + it("should find matching files", async () => { + const response = await client.find.files({ + query: { directory: fixtureRoot, query: "example.ts" }, + }); + const results = (response as any).data ?? []; + expect(results).toContain("src/example.ts"); + }); + + it("should find matching symbols", async () => { + const response = await client.find.symbols({ + query: { directory: fixtureRoot, query: "greet" }, + }); + const results = (response as any).data ?? []; + expect(results.some((symbol: any) => symbol.name === "greet")).toBe(true); + }); +}); diff --git a/server/packages/sandbox-agent/tests/opencode-compat/fixtures/search-fixture/lib/utils.py b/server/packages/sandbox-agent/tests/opencode-compat/fixtures/search-fixture/lib/utils.py new file mode 100644 index 0000000..369e9f7 --- /dev/null +++ b/server/packages/sandbox-agent/tests/opencode-compat/fixtures/search-fixture/lib/utils.py @@ -0,0 +1,7 @@ +class Finder: + def search(self, value): + return value + + +def find_symbol(value): + return value diff --git a/server/packages/sandbox-agent/tests/opencode-compat/fixtures/search-fixture/src/app.ts b/server/packages/sandbox-agent/tests/opencode-compat/fixtures/search-fixture/src/app.ts new file mode 100644 index 0000000..233d913 --- /dev/null +++ b/server/packages/sandbox-agent/tests/opencode-compat/fixtures/search-fixture/src/app.ts @@ -0,0 +1,11 @@ +export class SearchWidget { + render() { + return "SearchWidget"; + } +} + +export function findMatches(input: string) { + return input.includes("match"); +} + +export const SEARCH_TOKEN = "SearchWidget"; diff --git a/server/packages/sandbox-agent/tests/opencode-compat/fixtures/search-repo/README.md b/server/packages/sandbox-agent/tests/opencode-compat/fixtures/search-repo/README.md new file mode 100644 index 0000000..a7aeb75 --- /dev/null +++ b/server/packages/sandbox-agent/tests/opencode-compat/fixtures/search-repo/README.md @@ -0,0 +1,3 @@ +Search fixture repo + +Needle line for text search. diff --git a/server/packages/sandbox-agent/tests/opencode-compat/fixtures/search-repo/src/example.ts b/server/packages/sandbox-agent/tests/opencode-compat/fixtures/search-repo/src/example.ts new file mode 100644 index 0000000..1aeadda --- /dev/null +++ b/server/packages/sandbox-agent/tests/opencode-compat/fixtures/search-repo/src/example.ts @@ -0,0 +1,13 @@ +export function greet(name: string): string { + return `Hello, ${name}`; +} + +export class Greeter { + constructor(private readonly name: string) {} + + sayHello(): string { + return greet(this.name); + } +} + +export const DEFAULT_MESSAGE = "Needle says hello"; diff --git a/server/packages/sandbox-agent/tests/opencode-compat/formatter_lsp.test.ts b/server/packages/sandbox-agent/tests/opencode-compat/formatter_lsp.test.ts new file mode 100644 index 0000000..a99ba27 --- /dev/null +++ b/server/packages/sandbox-agent/tests/opencode-compat/formatter_lsp.test.ts @@ -0,0 +1,80 @@ +/** + * Tests for OpenCode-compatible formatter + LSP endpoints. + */ + +import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest"; +import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk"; +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn"; + +describe("OpenCode-compatible Formatter + LSP status", () => { + let handle: SandboxAgentHandle; + let client: OpencodeClient; + let workspaceDir: string; + + beforeAll(async () => { + await buildSandboxAgent(); + }); + + beforeEach(async () => { + workspaceDir = await mkdtemp(join(tmpdir(), "opencode-compat-")); + await writeFile(join(workspaceDir, "main.rs"), "fn main() {}\n"); + await writeFile(join(workspaceDir, "app.ts"), "const value = 1;\n"); + + handle = await spawnSandboxAgent({ + opencodeCompat: true, + env: { + OPENCODE_COMPAT_DIRECTORY: workspaceDir, + OPENCODE_COMPAT_WORKTREE: workspaceDir, + }, + }); + client = createOpencodeClient({ + baseUrl: `${handle.baseUrl}/opencode`, + headers: { Authorization: `Bearer ${handle.token}` }, + }); + }); + + afterEach(async () => { + await handle?.dispose(); + if (workspaceDir) { + await rm(workspaceDir, { recursive: true, force: true }); + } + }); + + it("should report formatter status for workspace languages", async () => { + const response = await client.formatter.status({ query: { directory: workspaceDir } }); + const entries = response.data ?? []; + + expect(Array.isArray(entries)).toBe(true); + expect(entries.length).toBeGreaterThan(0); + + const hasRust = entries.some((entry: any) => entry.extensions?.includes(".rs")); + const hasTs = entries.some((entry: any) => entry.extensions?.includes(".ts")); + expect(hasRust).toBe(true); + expect(hasTs).toBe(true); + + for (const entry of entries) { + expect(typeof entry.enabled).toBe("boolean"); + } + }); + + it("should report lsp status for workspace languages", async () => { + const response = await client.lsp.status({ query: { directory: workspaceDir } }); + const entries = response.data ?? []; + + expect(Array.isArray(entries)).toBe(true); + expect(entries.length).toBeGreaterThan(0); + + const hasRust = entries.some((entry: any) => entry.id === "rust-analyzer"); + const hasTs = entries.some((entry: any) => entry.id === "typescript-language-server"); + expect(hasRust).toBe(true); + expect(hasTs).toBe(true); + + for (const entry of entries) { + expect(entry.root).toBe(workspaceDir); + expect(["connected", "error"]).toContain(entry.status); + } + }); +}); diff --git a/server/packages/sandbox-agent/tests/opencode-compat/package.json b/server/packages/sandbox-agent/tests/opencode-compat/package.json index 60cce21..ab60dcd 100644 --- a/server/packages/sandbox-agent/tests/opencode-compat/package.json +++ b/server/packages/sandbox-agent/tests/opencode-compat/package.json @@ -9,10 +9,12 @@ }, "devDependencies": { "@types/node": "^22.0.0", + "@types/ws": "^8.5.10", "typescript": "^5.7.0", "vitest": "^3.0.0" }, "dependencies": { - "@opencode-ai/sdk": "^1.1.21" + "@opencode-ai/sdk": "^1.1.21", + "ws": "^8.18.0" } } diff --git a/server/packages/sandbox-agent/tests/opencode-compat/pty.test.ts b/server/packages/sandbox-agent/tests/opencode-compat/pty.test.ts new file mode 100644 index 0000000..bd1e8a6 --- /dev/null +++ b/server/packages/sandbox-agent/tests/opencode-compat/pty.test.ts @@ -0,0 +1,112 @@ +/** + * Tests for OpenCode-compatible PTY endpoints. + */ + +import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest"; +import { WebSocket } from "ws"; +import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn"; + +describe("OpenCode-compatible PTY API", () => { + let handle: SandboxAgentHandle; + + beforeAll(async () => { + await buildSandboxAgent(); + }); + + beforeEach(async () => { + handle = await spawnSandboxAgent({ opencodeCompat: true }); + }); + + afterEach(async () => { + await handle?.dispose(); + }); + + async function createPty(body: Record) { + const response = await fetch(`${handle.baseUrl}/opencode/pty`, { + method: "POST", + headers: { + Authorization: `Bearer ${handle.token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + const data = await response.json(); + return { response, data }; + } + + async function deletePty(id: string) { + await fetch(`${handle.baseUrl}/opencode/pty/${id}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${handle.token}` }, + }); + } + + async function connectPty(id: string): Promise { + const wsUrl = `${handle.baseUrl.replace("http", "ws")}/opencode/pty/${id}/connect`; + return new Promise((resolve, reject) => { + const ws = new WebSocket(wsUrl, { + headers: { Authorization: `Bearer ${handle.token}` }, + }); + ws.once("open", () => resolve(ws)); + ws.once("error", (err) => reject(err)); + }); + } + + function waitForOutput( + ws: WebSocket, + matcher: (text: string) => boolean, + timeoutMs = 5000 + ): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("timed out waiting for output")), timeoutMs); + const onMessage = (data: WebSocket.RawData) => { + const text = data.toString(); + if (matcher(text)) { + clearTimeout(timer); + ws.off("message", onMessage); + resolve(text); + } + }; + ws.on("message", onMessage); + }); + } + + it("should spawn a pty session", async () => { + const { response, data } = await createPty({ command: "sh" }); + + expect(response.ok).toBe(true); + expect(data.id).toMatch(/^pty_/); + expect(data.status).toBe("running"); + expect(typeof data.pid).toBe("number"); + + await deletePty(data.id); + }); + + it("should capture output from pty", async () => { + const { data } = await createPty({ command: "sh" }); + const ws = await connectPty(data.id); + + const outputPromise = waitForOutput(ws, (text) => text.includes("hello-pty")); + ws.send("echo hello-pty\n"); + + const output = await outputPromise; + expect(output).toContain("hello-pty"); + + ws.close(); + await deletePty(data.id); + }); + + it("should echo input back through pty", async () => { + const { data } = await createPty({ command: "cat" }); + const ws = await connectPty(data.id); + + const outputPromise = waitForOutput(ws, (text) => text.includes("ping-pty")); + ws.send("ping-pty\n"); + + const output = await outputPromise; + expect(output).toContain("ping-pty"); + + ws.close(); + await deletePty(data.id); + }); +}); diff --git a/server/packages/sandbox-agent/tests/opencode-compat/search.test.ts b/server/packages/sandbox-agent/tests/opencode-compat/search.test.ts new file mode 100644 index 0000000..906e5ed --- /dev/null +++ b/server/packages/sandbox-agent/tests/opencode-compat/search.test.ts @@ -0,0 +1,97 @@ +/** + * Tests for OpenCode-compatible search endpoints. + */ + +import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const fixtureRoot = resolve(__dirname, "fixtures/search-fixture"); + +describe("OpenCode-compatible Search API", () => { + let handle: SandboxAgentHandle; + + beforeAll(async () => { + await buildSandboxAgent(); + }); + + beforeEach(async () => { + handle = await spawnSandboxAgent({ + opencodeCompat: true, + env: { + OPENCODE_COMPAT_DIRECTORY: fixtureRoot, + OPENCODE_COMPAT_WORKTREE: fixtureRoot, + }, + }); + }); + + afterEach(async () => { + await handle?.dispose(); + }); + + it("should return text matches", async () => { + const url = new URL(`${handle.baseUrl}/opencode/find`); + url.searchParams.set("pattern", "SearchWidget"); + url.searchParams.set("limit", "10"); + + const response = await fetch(url, { + headers: { Authorization: `Bearer ${handle.token}` }, + }); + expect(response.ok).toBe(true); + + const data = await response.json(); + expect(Array.isArray(data)).toBe(true); + + const hit = data.find((entry: any) => entry?.path?.text?.endsWith("src/app.ts")); + expect(hit).toBeDefined(); + expect(hit?.lines?.text).toContain("SearchWidget"); + }); + + it("should respect case-insensitive search", async () => { + const url = new URL(`${handle.baseUrl}/opencode/find`); + url.searchParams.set("pattern", "searchwidget"); + url.searchParams.set("caseSensitive", "false"); + url.searchParams.set("limit", "10"); + + const response = await fetch(url, { + headers: { Authorization: `Bearer ${handle.token}` }, + }); + expect(response.ok).toBe(true); + + const data = await response.json(); + const hit = data.find((entry: any) => entry?.path?.text?.endsWith("src/app.ts")); + expect(hit).toBeDefined(); + }); + + it("should return file and symbol hits", async () => { + const filesUrl = new URL(`${handle.baseUrl}/opencode/find/file`); + filesUrl.searchParams.set("query", "src/*.ts"); + filesUrl.searchParams.set("limit", "10"); + + const filesResponse = await fetch(filesUrl, { + headers: { Authorization: `Bearer ${handle.token}` }, + }); + expect(filesResponse.ok).toBe(true); + + const files = await filesResponse.json(); + expect(Array.isArray(files)).toBe(true); + expect(files).toContain("src/app.ts"); + + const symbolsUrl = new URL(`${handle.baseUrl}/opencode/find/symbol`); + symbolsUrl.searchParams.set("query", "findMatches"); + symbolsUrl.searchParams.set("limit", "10"); + + const symbolsResponse = await fetch(symbolsUrl, { + headers: { Authorization: `Bearer ${handle.token}` }, + }); + expect(symbolsResponse.ok).toBe(true); + + const symbols = await symbolsResponse.json(); + expect(Array.isArray(symbols)).toBe(true); + const match = symbols.find((entry: any) => entry?.name === "findMatches"); + expect(match).toBeDefined(); + expect(match?.location?.uri).toContain("src/app.ts"); + }); +}); diff --git a/server/packages/sandbox-agent/tests/opencode-compat/session.test.ts b/server/packages/sandbox-agent/tests/opencode-compat/session.test.ts index c778691..7b003dc 100644 --- a/server/packages/sandbox-agent/tests/opencode-compat/session.test.ts +++ b/server/packages/sandbox-agent/tests/opencode-compat/session.test.ts @@ -13,12 +13,16 @@ */ import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from "vitest"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk"; import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn"; describe("OpenCode-compatible Session API", () => { let handle: SandboxAgentHandle; let client: OpencodeClient; + let stateDir: string; beforeAll(async () => { // Build the binary if needed @@ -27,7 +31,11 @@ describe("OpenCode-compatible Session API", () => { beforeEach(async () => { // Spawn a fresh sandbox-agent instance for each test - handle = await spawnSandboxAgent({ opencodeCompat: true }); + stateDir = await mkdtemp(join(tmpdir(), "opencode-state-")); + handle = await spawnSandboxAgent({ + opencodeCompat: true, + env: { OPENCODE_COMPAT_STATE: stateDir }, + }); client = createOpencodeClient({ baseUrl: `${handle.baseUrl}/opencode`, headers: { Authorization: `Bearer ${handle.token}` }, @@ -145,4 +153,41 @@ describe("OpenCode-compatible Session API", () => { expect(response.data?.title).toBe("Keep"); }); }); + + describe("session.persistence", () => { + it("should persist sessions across restarts", async () => { + const created = await client.session.create({ body: { title: "Persistent" } }); + const sessionId = created.data?.id!; + + await client.session.update({ + path: { id: sessionId }, + body: { title: "Updated" }, + }); + + await fetch(`${handle.baseUrl}/opencode/session/${sessionId}/share`, { + method: "POST", + headers: { Authorization: `Bearer ${handle.token}` }, + }); + + await handle.dispose(); + + handle = await spawnSandboxAgent({ + opencodeCompat: true, + env: { OPENCODE_COMPAT_STATE: stateDir }, + }); + client = createOpencodeClient({ + baseUrl: `${handle.baseUrl}/opencode`, + headers: { Authorization: `Bearer ${handle.token}` }, + }); + + const list = await client.session.list(); + const persisted = list.data?.find((session) => session.id === sessionId); + expect(persisted).toBeDefined(); + expect(persisted?.title).toBe("Updated"); + expect(persisted?.share?.url).toContain(sessionId); + + const next = await client.session.create(); + expect(next.data?.id).not.toBe(sessionId); + }); + }); }); diff --git a/server/packages/sandbox-agent/tests/opencode-compat/tools.test.ts b/server/packages/sandbox-agent/tests/opencode-compat/tools.test.ts index 4cdda8f..71d67f6 100644 --- a/server/packages/sandbox-agent/tests/opencode-compat/tools.test.ts +++ b/server/packages/sandbox-agent/tests/opencode-compat/tools.test.ts @@ -37,6 +37,8 @@ describe("OpenCode-compatible Tool + File Actions", () => { tool: false, file: false, edited: false, + pending: false, + completed: false, }; const waiter = new Promise((resolve, reject) => { @@ -48,6 +50,12 @@ describe("OpenCode-compatible Tool + File Actions", () => { const part = event.properties?.part; if (part?.type === "tool") { tracker.tool = true; + if (part?.state?.status === "pending") { + tracker.pending = true; + } + if (part?.state?.status === "completed") { + tracker.completed = true; + } } if (part?.type === "file") { tracker.file = true; @@ -56,7 +64,13 @@ describe("OpenCode-compatible Tool + File Actions", () => { if (event.type === "file.edited") { tracker.edited = true; } - if (tracker.tool && tracker.file && tracker.edited) { + if ( + tracker.tool && + tracker.file && + tracker.edited && + tracker.pending && + tracker.completed + ) { clearTimeout(timeout); resolve(); break; @@ -82,4 +96,49 @@ describe("OpenCode-compatible Tool + File Actions", () => { expect(tracker.file).toBe(true); expect(tracker.edited).toBe(true); }); + + it("should emit tool lifecycle states", async () => { + const eventStream = await client.event.subscribe(); + const statuses = new Set(); + + const waiter = new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error("Timed out waiting for tool lifecycle")), 15_000); + (async () => { + try { + for await (const event of (eventStream as any).stream) { + if (event.type === "message.part.updated") { + const part = event.properties?.part; + if (part?.type === "tool") { + const status = part?.state?.status; + if (status) { + statuses.add(status); + } + } + } + if (statuses.has("pending") && statuses.has("running") && (statuses.has("completed") || statuses.has("error"))) { + clearTimeout(timeout); + resolve(); + break; + } + } + } catch (err) { + clearTimeout(timeout); + reject(err); + } + })(); + }); + + await client.session.prompt({ + path: { id: sessionId }, + body: { + model: { providerID: "sandbox-agent", modelID: "mock" }, + parts: [{ type: "text", text: "tool" }], + }, + }); + + await waiter; + expect(statuses.has("pending")).toBe(true); + expect(statuses.has("running")).toBe(true); + expect(statuses.has("completed") || statuses.has("error")).toBe(true); + }); }); diff --git a/server/packages/sandbox-agent/tests/opencode_mcp.rs b/server/packages/sandbox-agent/tests/opencode_mcp.rs new file mode 100644 index 0000000..ebac7ec --- /dev/null +++ b/server/packages/sandbox-agent/tests/opencode_mcp.rs @@ -0,0 +1,186 @@ +use std::sync::Arc; + +use axum::extract::State; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::IntoResponse; +use axum::routing::post; +use axum::{Json, Router}; +use serde_json::{json, Value}; +use tokio::net::TcpListener; + +include!("common/http.rs"); + +#[derive(Clone)] +struct McpTestState { + token: String, +} + +async fn mcp_handler( + State(state): State>, + headers: HeaderMap, + Json(body): Json, +) -> impl IntoResponse { + let expected = format!("Bearer {}", state.token); + let auth = headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()); + if auth != Some(expected.as_str()) { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "unauthorized"})), + ); + } + + let method = body.get("method").and_then(|value| value.as_str()).unwrap_or(""); + let id = body.get("id").cloned().unwrap_or_else(|| json!(null)); + match method { + "initialize" => ( + StatusCode::OK, + Json(json!({ + "jsonrpc": "2.0", + "id": id, + "result": {"serverInfo": {"name": "mcp-test", "version": "0.1.0"}} + })), + ), + "tools/list" => ( + StatusCode::OK, + Json(json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "tools": [ + { + "name": "weather", + "description": "Get weather", + "inputSchema": { + "type": "object", + "properties": { + "city": {"type": "string"} + }, + "required": ["city"] + } + } + ] + } + })), + ), + _ => ( + StatusCode::BAD_REQUEST, + Json(json!({ + "jsonrpc": "2.0", + "id": id, + "error": {"code": -32601, "message": "method not found"} + })), + ), + } +} + +async fn spawn_mcp_server(token: &str) -> (String, tokio::task::JoinHandle<()>) { + let state = Arc::new(McpTestState { + token: token.to_string(), + }); + let app = Router::new().route("/mcp", post(mcp_handler)).with_state(state); + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind mcp listener"); + let addr = listener.local_addr().expect("local addr"); + let handle = tokio::spawn(async move { + axum::serve(listener, app).await.expect("mcp server"); + }); + (format!("http://{}/mcp", addr), handle) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn opencode_mcp_auth_and_tools() { + let token = "mcp-test-token"; + let (mcp_url, handle) = spawn_mcp_server(token).await; + let app = TestApp::new(); + + let (status, payload) = send_json( + &app.app, + Method::POST, + "/opencode/mcp", + Some(json!({ + "name": "test", + "config": { + "type": "remote", + "url": mcp_url, + "oauth": {}, + "headers": {} + } + })), + ) + .await; + assert_eq!(status, StatusCode::OK, "register mcp server"); + assert_eq!( + payload + .get("test") + .and_then(|value| value.get("status")) + .and_then(|value| value.as_str()), + Some("needs_auth") + ); + + let (status, payload) = send_json(&app.app, Method::POST, "/opencode/mcp/test/auth", None).await; + assert_eq!(status, StatusCode::OK, "start mcp auth"); + assert!( + payload + .get("authorizationUrl") + .and_then(|value| value.as_str()) + .is_some(), + "authorizationUrl missing" + ); + + let (status, payload) = send_json( + &app.app, + Method::POST, + "/opencode/mcp/test/auth/callback", + Some(json!({"code": token})), + ) + .await; + assert_eq!(status, StatusCode::OK, "complete mcp auth"); + assert_eq!( + payload + .get("status") + .and_then(|value| value.as_str()), + Some("connected") + ); + + let (status, payload) = + send_json(&app.app, Method::POST, "/opencode/mcp/test/connect", None).await; + assert_eq!(status, StatusCode::OK, "connect mcp server"); + assert_eq!(payload, json!(true)); + + let (status, payload) = send_json( + &app.app, + Method::GET, + "/opencode/experimental/tool/ids?provider=sandbox-agent&model=mock", + None, + ) + .await; + assert_eq!(status, StatusCode::OK, "tool ids"); + let ids = payload.as_array().expect("tool ids array"); + assert!( + ids.contains(&Value::String("mcp:test:weather".to_string())), + "missing tool id" + ); + + let (status, payload) = send_json( + &app.app, + Method::GET, + "/opencode/experimental/tool?provider=sandbox-agent&model=mock", + None, + ) + .await; + assert_eq!(status, StatusCode::OK, "tool list"); + let tools = payload.as_array().expect("tools array"); + let tool = tools + .iter() + .find(|tool| tool.get("id").and_then(|value| value.as_str()) == Some("mcp:test:weather")) + .expect("mcp tool entry"); + assert_eq!( + tool.get("description").and_then(|value| value.as_str()), + Some("Get weather") + ); + + handle.abort(); +} diff --git a/server/packages/universal-agent-schema/src/agents/codex.rs b/server/packages/universal-agent-schema/src/agents/codex.rs index 470e406..d2367e4 100644 --- a/server/packages/universal-agent-schema/src/agents/codex.rs +++ b/server/packages/universal-agent-schema/src/agents/codex.rs @@ -6,6 +6,7 @@ use crate::{ ItemStatus, ReasoningVisibility, SessionEndReason, SessionEndedData, SessionStartedData, TerminatedBy, UniversalEventData, UniversalEventType, UniversalItem, }; +use serde_json::Value; /// Convert a Codex ServerNotification to universal events. pub fn notification_to_universal( @@ -257,6 +258,26 @@ fn thread_item_to_item(item: &schema::ThreadItem, status: ItemStatus) -> Univers status: exec_status, .. } => { + if status == ItemStatus::InProgress { + let arguments = serde_json::json!({ + "command": command, + "cwd": cwd, + }) + .to_string(); + return UniversalItem { + item_id: String::new(), + native_item_id: Some(id.clone()), + parent_id: None, + kind: ItemKind::ToolCall, + role: Some(ItemRole::Assistant), + content: vec![ContentPart::ToolCall { + name: "command_execution".to_string(), + arguments, + call_id: id.clone(), + }], + status, + }; + } let mut parts = Vec::new(); if let Some(output) = aggregated_output { parts.push(ContentPart::ToolResult { @@ -285,20 +306,56 @@ fn thread_item_to_item(item: &schema::ThreadItem, status: ItemStatus) -> Univers changes, id, status: file_status, - } => UniversalItem { - item_id: String::new(), - native_item_id: Some(id.clone()), - parent_id: None, - kind: ItemKind::ToolResult, - role: Some(ItemRole::Tool), - content: vec![ContentPart::Json { - json: serde_json::json!({ + } => { + if status == ItemStatus::InProgress { + let arguments = serde_json::json!({ "changes": changes, "status": format!("{:?}", file_status) - }), - }], - status, - }, + }) + .to_string(); + return UniversalItem { + item_id: String::new(), + native_item_id: Some(id.clone()), + parent_id: None, + kind: ItemKind::ToolCall, + role: Some(ItemRole::Assistant), + content: vec![ContentPart::ToolCall { + name: "file_change".to_string(), + arguments, + call_id: id.clone(), + }], + status, + }; + } + let mut parts = Vec::new(); + let output = serde_json::json!({ + "changes": changes, + "status": format!("{:?}", file_status) + }) + .to_string(); + parts.push(ContentPart::ToolResult { + call_id: id.clone(), + output, + }); + for change in changes { + let (action, target_path) = file_action_from_change_kind(&change.kind); + parts.push(ContentPart::FileRef { + path: change.path.clone(), + action, + diff: Some(change.diff.clone()), + target_path, + }); + } + UniversalItem { + item_id: String::new(), + native_item_id: Some(id.clone()), + parent_id: None, + kind: ItemKind::ToolResult, + role: Some(ItemRole::Tool), + content: parts, + status, + } + } schema::ThreadItem::McpToolCall { arguments, error, @@ -433,6 +490,34 @@ fn thread_item_to_item(item: &schema::ThreadItem, status: ItemStatus) -> Univers } } +fn file_action_from_change_kind( + kind: &schema::PatchChangeKind, +) -> (crate::FileAction, Option) { + let value = serde_json::to_value(kind).ok(); + let kind_type = value + .as_ref() + .and_then(|v| v.get("type")) + .and_then(Value::as_str) + .unwrap_or("update"); + let move_path = value + .as_ref() + .and_then(|v| v.get("move_path")) + .and_then(Value::as_str) + .map(|v| v.to_string()); + match kind_type { + "add" => (crate::FileAction::Write, None), + "delete" => (crate::FileAction::Delete, None), + "update" => { + if let Some(target) = move_path { + (crate::FileAction::Rename, Some(target)) + } else { + (crate::FileAction::Patch, None) + } + } + _ => (crate::FileAction::Patch, None), + } +} + fn status_item(label: &str, detail: Option) -> UniversalItem { UniversalItem { item_id: String::new(), diff --git a/server/packages/universal-agent-schema/src/agents/opencode.rs b/server/packages/universal-agent-schema/src/agents/opencode.rs index 4dad152..793d286 100644 --- a/server/packages/universal-agent-schema/src/agents/opencode.rs +++ b/server/packages/universal-agent-schema/src/agents/opencode.rs @@ -477,6 +477,7 @@ fn file_part_to_content(file_part: &schema::FilePart) -> ContentPart { path, action, diff: None, + target_path: None, } } diff --git a/server/packages/universal-agent-schema/src/lib.rs b/server/packages/universal-agent-schema/src/lib.rs index f4735f0..8dcbfeb 100644 --- a/server/packages/universal-agent-schema/src/lib.rs +++ b/server/packages/universal-agent-schema/src/lib.rs @@ -243,6 +243,8 @@ pub enum ContentPart { path: String, action: FileAction, diff: Option, + #[serde(skip_serializing_if = "Option::is_none")] + target_path: Option, }, Reasoning { text: String, @@ -258,12 +260,14 @@ pub enum ContentPart { }, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, ToSchema)] #[serde(rename_all = "snake_case")] pub enum FileAction { Read, Write, Patch, + Rename, + Delete, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]