| 1 | //! Minimal Agent Client Protocol stdio adapter. |
| 2 | //! |
| 3 | //! This intentionally starts with the ACP baseline: initialize, new session, |
| 4 | //! prompt, and cancel. It keeps stdout protocol-clean for editor clients and |
| 5 | //! routes prompts through the same configured DeepSeek client as one-shot CLI |
| 6 | //! mode. |
| 7 | |
| 8 | use std::collections::HashMap; |
| 9 | use std::path::PathBuf; |
| 10 | |
| 11 | use anyhow::{Result, anyhow}; |
| 12 | use serde_json::{Value, json}; |
| 13 | use tokio::io::{AsyncBufReadExt, AsyncWrite, AsyncWriteExt, BufReader}; |
| 14 | |
| 15 | use crate::client::DeepSeekClient; |
| 16 | use crate::config::Config; |
| 17 | use crate::llm_client::LlmClient; |
| 18 | use crate::models::{ContentBlock, Message, MessageRequest, SystemPrompt}; |
| 19 | |
| 20 | const ACP_PROTOCOL_VERSION: u64 = 1; |
| 21 | |
| 22 | pub async fn run_acp_server(config: Config, model: String, default_cwd: PathBuf) -> Result<()> { |
| 23 | let stdin = tokio::io::stdin(); |
| 24 | let stdout = tokio::io::stdout(); |
| 25 | let mut reader = BufReader::new(stdin).lines(); |
| 26 | let mut writer = tokio::io::BufWriter::new(stdout); |
| 27 | let mut server = AcpServer::new(config, model, default_cwd); |
| 28 | |
| 29 | while let Some(line) = reader.next_line().await? { |
| 30 | if line.trim().is_empty() { |
| 31 | continue; |
| 32 | } |
| 33 | |
| 34 | let message: Value = match serde_json::from_str(&line) { |
| 35 | Ok(value) => value, |
| 36 | Err(err) => { |
| 37 | write_jsonrpc_error(&mut writer, None, -32700, format!("invalid json: {err}")) |
| 38 | .await?; |
| 39 | continue; |
| 40 | } |
| 41 | }; |
| 42 | |
| 43 | if message.get("jsonrpc").and_then(Value::as_str) != Some("2.0") { |
| 44 | write_jsonrpc_error( |
| 45 | &mut writer, |
| 46 | message.get("id").cloned(), |
| 47 | -32600, |
| 48 | "jsonrpc version must be 2.0", |
| 49 | ) |
| 50 | .await?; |
| 51 | continue; |
| 52 | } |
| 53 | |
| 54 | let id = message.get("id").cloned(); |
| 55 | let method = match message.get("method").and_then(Value::as_str) { |
| 56 | Some(method) => method, |
| 57 | None => { |
| 58 | write_jsonrpc_error(&mut writer, id, -32600, "missing method").await?; |
| 59 | continue; |
| 60 | } |
| 61 | }; |
| 62 | let params = message.get("params").cloned().unwrap_or_else(|| json!({})); |
| 63 | |
| 64 | match server.handle_request(method, params, &mut writer).await { |
| 65 | Ok(AcpDispatch::Response(result)) => { |
| 66 | if let Some(id) = id { |
| 67 | write_jsonrpc_result(&mut writer, id, result).await?; |
| 68 | } |
| 69 | } |
| 70 | Ok(AcpDispatch::Shutdown) => { |
| 71 | if let Some(id) = id { |
| 72 | write_jsonrpc_result(&mut writer, id, json!(null)).await?; |
| 73 | } |
| 74 | break; |
| 75 | } |
| 76 | Err(err) => { |
| 77 | write_jsonrpc_error(&mut writer, id, err.code, err.message).await?; |
| 78 | } |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | Ok(()) |
| 83 | } |
| 84 | |
| 85 | struct AcpServer { |
| 86 | config: Config, |
| 87 | model: String, |
| 88 | default_cwd: PathBuf, |
| 89 | sessions: HashMap<String, AcpSession>, |
| 90 | } |
| 91 | |
| 92 | struct AcpSession { |
| 93 | cwd: PathBuf, |
| 94 | } |
| 95 | |
| 96 | enum AcpDispatch { |
| 97 | Response(Value), |
| 98 | Shutdown, |
| 99 | } |
| 100 | |
| 101 | struct AcpError { |
| 102 | code: i32, |
| 103 | message: String, |
| 104 | } |
| 105 | |
| 106 | impl AcpServer { |
| 107 | fn new(config: Config, model: String, default_cwd: PathBuf) -> Self { |
| 108 | Self { |
| 109 | config, |
| 110 | model, |
| 111 | default_cwd, |
| 112 | sessions: HashMap::new(), |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | async fn handle_request<W>( |
| 117 | &mut self, |
| 118 | method: &str, |
| 119 | params: Value, |
| 120 | writer: &mut W, |
| 121 | ) -> std::result::Result<AcpDispatch, AcpError> |
| 122 | where |
| 123 | W: AsyncWrite + Unpin, |
| 124 | { |
| 125 | match method { |
| 126 | "initialize" => Ok(AcpDispatch::Response(initialize_result( |
| 127 | params.get("protocolVersion").and_then(Value::as_u64), |
| 128 | ))), |
| 129 | "session/new" => Ok(AcpDispatch::Response(self.new_session(params)?)), |
| 130 | "session/prompt" => { |
| 131 | self.prompt(params, writer).await?; |
| 132 | Ok(AcpDispatch::Response(json!({ "stopReason": "end_turn" }))) |
| 133 | } |
| 134 | "session/cancel" => Ok(AcpDispatch::Response(json!(null))), |
| 135 | "shutdown" => Ok(AcpDispatch::Shutdown), |
| 136 | _ => Err(AcpError::method_not_found(method)), |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | fn new_session(&mut self, params: Value) -> std::result::Result<Value, AcpError> { |
| 141 | let cwd = params |
| 142 | .get("cwd") |
| 143 | .and_then(Value::as_str) |
| 144 | .map(PathBuf::from) |
| 145 | .unwrap_or_else(|| self.default_cwd.clone()); |
| 146 | let session_id = format!("deepseek-{}", uuid::Uuid::new_v4()); |
| 147 | self.sessions.insert(session_id.clone(), AcpSession { cwd }); |
| 148 | Ok(json!({ "sessionId": session_id })) |
| 149 | } |
| 150 | |
| 151 | async fn prompt<W>(&self, params: Value, writer: &mut W) -> std::result::Result<(), AcpError> |
| 152 | where |
| 153 | W: AsyncWrite + Unpin, |
| 154 | { |
| 155 | let session_id = params |
| 156 | .get("sessionId") |
| 157 | .and_then(Value::as_str) |
| 158 | .ok_or_else(|| AcpError::invalid_params("sessionId is required"))?; |
| 159 | let session = self |
| 160 | .sessions |
| 161 | .get(session_id) |
| 162 | .ok_or_else(|| AcpError::invalid_params("unknown sessionId"))?; |
| 163 | let prompt = extract_prompt_text(params.get("prompt")) |
| 164 | .filter(|text| !text.trim().is_empty()) |
| 165 | .ok_or_else(|| AcpError::invalid_params("prompt must include text content"))?; |
| 166 | |
| 167 | let output = self |
| 168 | .run_prompt(&prompt, &session.cwd) |
| 169 | .await |
| 170 | .map_err(|err| AcpError::internal(err.to_string()))?; |
| 171 | |
| 172 | if !output.is_empty() { |
| 173 | write_session_update(writer, session_id, output) |
| 174 | .await |
| 175 | .map_err(|err| AcpError::internal(err.to_string()))?; |
| 176 | } |
| 177 | |
| 178 | Ok(()) |
| 179 | } |
| 180 | |
| 181 | async fn run_prompt(&self, prompt: &str, cwd: &PathBuf) -> Result<String> { |
| 182 | let _cwd_guard = ScopedCurrentDir::new(cwd)?; |
| 183 | let client = DeepSeekClient::new(&self.config)?; |
| 184 | let route = crate::resolve_cli_auto_route(&self.config, &self.model, prompt).await; |
| 185 | let reasoning_effort = route |
| 186 | .reasoning_effort |
| 187 | .map(|effort| effort.as_setting().to_string()); |
| 188 | |
| 189 | let request = MessageRequest { |
| 190 | model: route.model, |
| 191 | messages: vec![Message { |
| 192 | role: "user".to_string(), |
| 193 | content: vec![ContentBlock::Text { |
| 194 | text: prompt.to_string(), |
| 195 | cache_control: None, |
| 196 | }], |
| 197 | }], |
| 198 | max_tokens: 4096, |
| 199 | system: Some(SystemPrompt::Text( |
| 200 | "You are a coding assistant inside an ACP-compatible editor. Give concise, actionable responses.".to_string(), |
| 201 | )), |
| 202 | tools: None, |
| 203 | tool_choice: None, |
| 204 | metadata: None, |
| 205 | thinking: None, |
| 206 | reasoning_effort, |
| 207 | stream: Some(false), |
| 208 | temperature: Some(0.2), |
| 209 | top_p: Some(0.9), |
| 210 | }; |
| 211 | |
| 212 | let response = client.create_message(request).await?; |
| 213 | let mut output = String::new(); |
| 214 | for block in response.content { |
| 215 | if let ContentBlock::Text { text, .. } = block { |
| 216 | output.push_str(&text); |
| 217 | } |
| 218 | } |
| 219 | Ok(output) |
| 220 | } |
| 221 | } |
| 222 | |
| 223 | struct ScopedCurrentDir { |
| 224 | prior: PathBuf, |
| 225 | } |
| 226 | |
| 227 | impl ScopedCurrentDir { |
| 228 | fn new(cwd: &PathBuf) -> Result<Self> { |
| 229 | let prior = std::env::current_dir()?; |
| 230 | if cwd.as_os_str().is_empty() { |
| 231 | return Ok(Self { prior }); |
| 232 | } |
| 233 | std::env::set_current_dir(cwd) |
| 234 | .map_err(|err| anyhow!("failed to enter ACP session cwd {}: {err}", cwd.display()))?; |
| 235 | Ok(Self { prior }) |
| 236 | } |
| 237 | } |
| 238 | |
| 239 | impl Drop for ScopedCurrentDir { |
| 240 | fn drop(&mut self) { |
| 241 | let _ = std::env::set_current_dir(&self.prior); |
| 242 | } |
| 243 | } |
| 244 | |
| 245 | impl AcpError { |
| 246 | fn invalid_params(message: impl Into<String>) -> Self { |
| 247 | Self { |
| 248 | code: -32602, |
| 249 | message: message.into(), |
| 250 | } |
| 251 | } |
| 252 | |
| 253 | fn method_not_found(method: &str) -> Self { |
| 254 | Self { |
| 255 | code: -32601, |
| 256 | message: format!("method not found: {method}"), |
| 257 | } |
| 258 | } |
| 259 | |
| 260 | fn internal(message: impl Into<String>) -> Self { |
| 261 | Self { |
| 262 | code: -32603, |
| 263 | message: message.into(), |
| 264 | } |
| 265 | } |
| 266 | } |
| 267 | |
| 268 | fn initialize_result(client_protocol_version: Option<u64>) -> Value { |
| 269 | json!({ |
| 270 | "protocolVersion": client_protocol_version |
| 271 | .map(|version| version.min(ACP_PROTOCOL_VERSION)) |
| 272 | .unwrap_or(ACP_PROTOCOL_VERSION), |
| 273 | "agentCapabilities": { |
| 274 | "loadSession": false, |
| 275 | "promptCapabilities": { |
| 276 | "image": false, |
| 277 | "audio": false, |
| 278 | "embeddedContext": true |
| 279 | }, |
| 280 | "mcpCapabilities": { |
| 281 | "http": false, |
| 282 | "sse": false |
| 283 | }, |
| 284 | "sessionCapabilities": {} |
| 285 | }, |
| 286 | "agentInfo": { |
| 287 | "name": "deepseek", |
| 288 | "title": "DeepSeek TUI", |
| 289 | "version": env!("CARGO_PKG_VERSION") |
| 290 | }, |
| 291 | "authMethods": [] |
| 292 | }) |
| 293 | } |
| 294 | |
| 295 | fn extract_prompt_text(prompt: Option<&Value>) -> Option<String> { |
| 296 | match prompt? { |
| 297 | Value::String(text) => Some(text.clone()), |
| 298 | Value::Array(blocks) => { |
| 299 | let parts = blocks |
| 300 | .iter() |
| 301 | .filter_map(content_block_text) |
| 302 | .collect::<Vec<_>>(); |
| 303 | (!parts.is_empty()).then(|| parts.join("\n\n")) |
| 304 | } |
| 305 | _ => None, |
| 306 | } |
| 307 | } |
| 308 | |
| 309 | fn content_block_text(block: &Value) -> Option<String> { |
| 310 | match block.get("type").and_then(Value::as_str)? { |
| 311 | "text" => block |
| 312 | .get("text") |
| 313 | .and_then(Value::as_str) |
| 314 | .map(str::to_string), |
| 315 | "resource" => resource_text(block), |
| 316 | "resource_link" | "resourceLink" => resource_link_text(block), |
| 317 | _ => None, |
| 318 | } |
| 319 | } |
| 320 | |
| 321 | fn resource_text(block: &Value) -> Option<String> { |
| 322 | let resource = block.get("resource").unwrap_or(block); |
| 323 | if let Some(text) = resource.get("text").and_then(Value::as_str) { |
| 324 | return Some(text.to_string()); |
| 325 | } |
| 326 | resource_link_text(resource) |
| 327 | } |
| 328 | |
| 329 | fn resource_link_text(block: &Value) -> Option<String> { |
| 330 | let uri = block |
| 331 | .get("uri") |
| 332 | .or_else(|| block.pointer("/resource/uri")) |
| 333 | .and_then(Value::as_str)?; |
| 334 | Some(format!("@{uri}")) |
| 335 | } |
| 336 | |
| 337 | async fn write_session_update<W>(writer: &mut W, session_id: &str, text: String) -> Result<()> |
| 338 | where |
| 339 | W: AsyncWrite + Unpin, |
| 340 | { |
| 341 | let notification = json!({ |
| 342 | "jsonrpc": "2.0", |
| 343 | "method": "session/update", |
| 344 | "params": { |
| 345 | "sessionId": session_id, |
| 346 | "update": { |
| 347 | "sessionUpdate": "agent_message_chunk", |
| 348 | "content": { |
| 349 | "type": "text", |
| 350 | "text": text |
| 351 | } |
| 352 | } |
| 353 | } |
| 354 | }); |
| 355 | write_json_line(writer, notification).await |
| 356 | } |
| 357 | |
| 358 | async fn write_jsonrpc_result<W>(writer: &mut W, id: Value, result: Value) -> Result<()> |
| 359 | where |
| 360 | W: AsyncWrite + Unpin, |
| 361 | { |
| 362 | write_json_line( |
| 363 | writer, |
| 364 | json!({ |
| 365 | "jsonrpc": "2.0", |
| 366 | "id": id, |
| 367 | "result": result |
| 368 | }), |
| 369 | ) |
| 370 | .await |
| 371 | } |
| 372 | |
| 373 | async fn write_jsonrpc_error<W>( |
| 374 | writer: &mut W, |
| 375 | id: Option<Value>, |
| 376 | code: i32, |
| 377 | message: impl Into<String>, |
| 378 | ) -> Result<()> |
| 379 | where |
| 380 | W: AsyncWrite + Unpin, |
| 381 | { |
| 382 | write_json_line( |
| 383 | writer, |
| 384 | json!({ |
| 385 | "jsonrpc": "2.0", |
| 386 | "id": id, |
| 387 | "error": { |
| 388 | "code": code, |
| 389 | "message": message.into() |
| 390 | } |
| 391 | }), |
| 392 | ) |
| 393 | .await |
| 394 | } |
| 395 | |
| 396 | async fn write_json_line<W>(writer: &mut W, value: Value) -> Result<()> |
| 397 | where |
| 398 | W: AsyncWrite + Unpin, |
| 399 | { |
| 400 | writer.write_all(value.to_string().as_bytes()).await?; |
| 401 | writer.write_all(b"\n").await?; |
| 402 | writer.flush().await?; |
| 403 | Ok(()) |
| 404 | } |
| 405 | |
| 406 | #[cfg(test)] |
| 407 | mod tests { |
| 408 | use super::*; |
| 409 | |
| 410 | #[test] |
| 411 | fn initialize_advertises_baseline_acp_agent() { |
| 412 | let result = initialize_result(Some(1)); |
| 413 | |
| 414 | assert_eq!(result["protocolVersion"], 1); |
| 415 | assert_eq!(result["agentInfo"]["name"], "deepseek"); |
| 416 | assert_eq!(result["agentCapabilities"]["loadSession"], false); |
| 417 | assert_eq!( |
| 418 | result["agentCapabilities"]["promptCapabilities"]["embeddedContext"], |
| 419 | true |
| 420 | ); |
| 421 | assert_eq!(result["authMethods"], json!([])); |
| 422 | } |
| 423 | |
| 424 | #[test] |
| 425 | fn extract_prompt_text_accepts_text_and_resource_blocks() { |
| 426 | let prompt = json!([ |
| 427 | { "type": "text", "text": "Review this file" }, |
| 428 | { |
| 429 | "type": "resource", |
| 430 | "resource": { |
| 431 | "uri": "file:///tmp/app.rs", |
| 432 | "mimeType": "text/rust", |
| 433 | "text": "fn main() {}" |
| 434 | } |
| 435 | }, |
| 436 | { "type": "resource_link", "uri": "file:///tmp/lib.rs" } |
| 437 | ]); |
| 438 | |
| 439 | let text = extract_prompt_text(Some(&prompt)).expect("prompt text"); |
| 440 | |
| 441 | assert!(text.contains("Review this file")); |
| 442 | assert!(text.contains("fn main() {}")); |
| 443 | assert!(text.contains("@file:///tmp/lib.rs")); |
| 444 | } |
| 445 | |
| 446 | #[tokio::test] |
| 447 | async fn session_update_is_protocol_clean_single_line_json() { |
| 448 | let mut out = Vec::new(); |
| 449 | |
| 450 | write_session_update(&mut out, "sess_1", "hello\nworld".to_string()) |
| 451 | .await |
| 452 | .expect("write update"); |
| 453 | |
| 454 | let line = String::from_utf8(out).expect("utf8"); |
| 455 | assert_eq!(line.lines().count(), 1); |
| 456 | let value: Value = serde_json::from_str(line.trim()).expect("json"); |
| 457 | assert_eq!(value["method"], "session/update"); |
| 458 | assert_eq!(value["params"]["sessionId"], "sess_1"); |
| 459 | assert_eq!(value["params"]["update"]["content"]["text"], "hello\nworld"); |
| 460 | } |
| 461 | } |
| 462 |