返回 DeepSeek-TUI-2026
acp_server.rs
根目录 / crates / tui / src / acp_server.rs
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
462 lines RUST