1use std::collections::{BTreeMap, BTreeSet};
4use std::path::{Path, PathBuf};
5use std::sync::{Arc, Mutex, mpsc};
6use std::time::{Duration, Instant};
7
8use agent_client_protocol::schema::ProtocolVersion;
9use agent_client_protocol::schema::v1::*;
10use agent_client_protocol::{Agent, Client, ConnectionTo, Dispatch, Error, JsonRpcRequest, Lines, Result};
11use futures::channel::oneshot;
12use futures::future::{Either, select};
13use futures::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, FutureExt, Sink, Stream, StreamExt};
14use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
15
16use crate::agent::prompt_expects_workspace_write;
17use crate::agent::{ToolExecutionHook, ToolPermissionDecision, ToolPermissionHook};
18use crate::app::AgentEvent;
19use crate::cli::WebSearchMode;
20use crate::harness::HarnessHandle;
21use crate::mcp::config::{McpConfig, McpServerConfig, McpTransport};
22use crate::mcp::manager::McpManager;
23use crate::prompt;
24use crate::providers::{ProviderContentBlock, ProviderImageSource, ProviderMessage};
25use crate::server::ServerConfig;
26use crate::server::config_options::{ConfigOptionValue, acp_config_options, validate_config_option};
27use crate::server::events::{
28 SessionUpdateIntent, ToolStatusIntent, classify_tool, map_agent_event, sanitize_tool_payload,
29};
30use crate::server::session::{AcpServerSession, AcpSessionStore, validate_and_normalize_cwd};
31use crate::session::{
32 AcpPermissionOptionRecord, AcpSessionMetadata, SCHEMA_VERSION, SessionReader, SessionRecord, SessionWriter,
33};
34use crate::tools::shell::{ProcessKind, ProcessResult, ProcessStatus, ShellArgs, redact_secrets};
35use crate::tools::{AgentRunConfig, MAX_OUTPUT_BYTES, ToolOutput, ToolUseRequest};
36use thndrs_agent::CancelToken;
37
38const PERMISSION_POLL_INTERVAL: Duration = Duration::from_millis(100);
39const TERMINAL_POLL_INTERVAL: Duration = Duration::from_millis(100);
40
41#[derive(Clone, Debug)]
43pub struct ServerState {
44 config: ServerConfig,
45 inner: Arc<Mutex<ServerStateInner>>,
46}
47
48#[derive(Debug, Default)]
49struct ServerStateInner {
50 next_local_session: u64,
51 sessions: AcpSessionStore,
52 cancelled: BTreeSet<String>,
53 active_turn_cancels: BTreeMap<String, CancelToken>,
54 client_info: Option<Implementation>,
55 client_capabilities: ClientCapabilities,
56}
57
58impl ServerState {
59 pub fn new(config: ServerConfig) -> Self {
61 Self { config, inner: Arc::new(Mutex::new(ServerStateInner::default())) }
62 }
63
64 pub fn create_session(&self, cwd: &Path) -> Result<String, String> {
66 if !cwd.is_absolute() {
67 return Err(format!("ACP session cwd must be absolute: {}", cwd.display()));
68 }
69
70 let mut inner = self
71 .inner
72 .lock()
73 .map_err(|_| String::from("ACP server state lock poisoned"))?;
74 inner.next_local_session = inner.next_local_session.saturating_add(1);
75 let local_session_id = format!("local-acp-session-{}", inner.next_local_session);
76 let session_id = inner
77 .sessions
78 .create_session(&local_session_id, cwd, None)
79 .map_err(|err| err.to_string())?;
80 inner
81 .sessions
82 .update_session_metadata(
83 &session_id,
84 Some(self.config.model.clone()),
85 websearch_mode(&self.config.websearch),
86 )
87 .map_err(|err| err.to_string())?;
88 inner
89 .sessions
90 .update_session_reasoning(
91 &session_id,
92 Some(self.config.reasoning_effort),
93 Some(self.config.reasoning_summary),
94 )
95 .map_err(|err| err.to_string())?;
96
97 if let Some(session_dir) = &self.config.session_dir {
98 let mut writer = SessionWriter::create(
99 session_dir,
100 &local_session_id,
101 &cwd.display().to_string(),
102 "acp session",
103 "thndrs",
104 &self.config.model,
105 &self.config.websearch,
106 env!("CARGO_PKG_VERSION"),
107 None,
108 )
109 .map_err(|error| {
110 inner.sessions.remove_session(&session_id);
111 format!("ACP session writer initialization failed: {error}")
112 })?;
113
114 writer
115 .append_acp_session(&AcpSessionMetadata {
116 agent_name: String::from("thndrs"),
117 acp_session_id: session_id.clone(),
118 command: String::from("thndrs acp serve"),
119 protocol_version: format!("{:?}", ProtocolVersion::V1),
120 agent_info_name: None,
121 agent_info_version: None,
122 client_info_name: inner.client_info.as_ref().map(|info| info.name.clone()),
123 client_info_version: inner.client_info.as_ref().map(|info| info.version.clone()),
124 })
125 .map_err(|error| {
126 inner.sessions.remove_session(&session_id);
127 format!("ACP session metadata write failed: {error}")
128 })?;
129
130 inner
131 .sessions
132 .attach_session_writer(&session_id, writer)
133 .map_err(|error| {
134 inner.sessions.remove_session(&session_id);
135 error.to_string()
136 })?;
137 }
138 Ok(session_id)
139 }
140
141 fn record_client_info(&self, request: &InitializeRequest) {
142 if let Ok(mut inner) = self.inner.lock() {
143 inner.client_info = request.client_info.clone();
144 inner.client_capabilities = request.client_capabilities.clone();
145 }
146 }
147
148 fn client_fs_capabilities(&self) -> FileSystemCapabilities {
149 self.inner
150 .lock()
151 .map(|inner| inner.client_capabilities.fs.clone())
152 .unwrap_or_default()
153 }
154
155 pub fn client_can_run_terminal(&self) -> bool {
157 self.inner
158 .lock()
159 .map(|inner| inner.client_capabilities.terminal)
160 .unwrap_or(false)
161 }
162
163 pub fn client_can_read_text_files(&self) -> bool {
165 self.client_fs_capabilities().read_text_file
166 }
167
168 pub fn client_can_write_text_files(&self) -> bool {
170 self.client_fs_capabilities().write_text_file
171 }
172
173 fn config_options_for_session(&self, session_id: &str) -> Result<Vec<SessionConfigOption>, String> {
174 let session = self.session(session_id)?;
175 let model = session.metadata.model.unwrap_or_else(|| self.config.model.clone());
176 let websearch = session
177 .metadata
178 .websearch
179 .or_else(|| websearch_mode(&self.config.websearch))
180 .unwrap_or(WebSearchMode::DuckDuckGo);
181 let effort = session
182 .metadata
183 .reasoning_effort
184 .unwrap_or(self.config.reasoning_effort);
185 let summary = session
186 .metadata
187 .reasoning_summary
188 .unwrap_or(self.config.reasoning_summary);
189 Ok(acp_config_options(&model, websearch, effort, summary))
190 }
191
192 fn set_config_option(&self, session_id: &str, option: &ConfigOptionValue) -> Result<(), String> {
193 let mut inner = self
194 .inner
195 .lock()
196 .map_err(|_| String::from("ACP server state lock poisoned"))?;
197 let session = inner
198 .sessions
199 .session(session_id)
200 .cloned()
201 .ok_or_else(|| format!("missing session: {session_id}"))?;
202 if let ConfigOptionValue::ReasoningEffort(effort) = option {
203 let active_model = session.metadata.model.as_deref().unwrap_or(&self.config.model);
204 if !crate::providers::reasoning_option_is_supported(active_model, *effort) {
205 return Err(format!(
206 "reasoning control `{}` is not supported by {active_model}",
207 effort.label()
208 ));
209 }
210 }
211 let model = match option {
212 ConfigOptionValue::Model(model) => Some(model.clone()),
213 ConfigOptionValue::WebSearch(_)
214 | ConfigOptionValue::ReasoningEffort(_)
215 | ConfigOptionValue::ReasoningSummary(_) => session.metadata.model,
216 };
217 let websearch = match option {
218 ConfigOptionValue::Model(_)
219 | ConfigOptionValue::ReasoningEffort(_)
220 | ConfigOptionValue::ReasoningSummary(_) => session.metadata.websearch,
221 ConfigOptionValue::WebSearch(mode) => Some(*mode),
222 };
223 inner
224 .sessions
225 .update_session_metadata(session_id, model, websearch)
226 .map_err(|error| error.to_string())?;
227 let effort = match option {
228 ConfigOptionValue::ReasoningEffort(effort) => Some(*effort),
229 _ => session.metadata.reasoning_effort,
230 };
231 let summary = match option {
232 ConfigOptionValue::ReasoningSummary(summary) => Some(*summary),
233 _ => session.metadata.reasoning_summary,
234 };
235 inner
236 .sessions
237 .update_session_reasoning(session_id, effort, summary)
238 .map_err(|error| error.to_string())?;
239 Ok(())
240 }
241
242 fn append_record(&self, session_id: &str, record: SessionRecord) {
243 if let Ok(mut inner) = self.inner.lock()
244 && let Some(writer) = inner.sessions.session_writer_mut(session_id)
245 {
246 let _ = writer.append(record);
247 }
248 }
249
250 fn session(&self, session_id: &str) -> Result<AcpServerSession, String> {
251 let inner = self
252 .inner
253 .lock()
254 .map_err(|_| String::from("ACP server state lock poisoned"))?;
255 inner
256 .sessions
257 .session(session_id)
258 .cloned()
259 .ok_or_else(|| format!("unknown ACP session id `{session_id}`"))
260 }
261
262 fn attach_mcp_config(&self, session_id: &str, mcp_config: McpConfig) -> Result<(), String> {
263 let mut inner = self
264 .inner
265 .lock()
266 .map_err(|_| String::from("ACP server state lock poisoned"))?;
267 inner
268 .sessions
269 .attach_mcp_config(session_id, mcp_config)
270 .map_err(|error| error.to_string())
271 }
272
273 fn list_sessions(&self, cwd: Option<&Path>) -> Result<Vec<SessionInfo>, String> {
274 let mut sessions = BTreeMap::new();
275 if let Some(session_dir) = &self.config.session_dir {
276 for path in crate::session::list_session_files(session_dir) {
277 if let Some(info) = session_info_from_file(&path, cwd) {
278 sessions.insert(info.session_id.0.to_string(), info);
279 }
280 }
281 }
282
283 let inner = self
284 .inner
285 .lock()
286 .map_err(|_| String::from("ACP server state lock poisoned"))?;
287 for session in inner.sessions.sessions() {
288 if cwd.is_some_and(|expected| expected != session.cwd) {
289 continue;
290 }
291 sessions.insert(
292 session.acp_session_id.clone(),
293 SessionInfo::new(session.acp_session_id.clone(), session.cwd.clone())
294 .title(Some(session.metadata.local_session_id.clone())),
295 );
296 }
297
298 Ok(sessions.into_values().collect())
299 }
300
301 fn load_session(&self, session_id: &str, cwd: &Path) -> Result<Vec<SessionRecord>, String> {
302 let session_path = self.persisted_session_path(session_id)?;
303 let records = SessionReader::read_records(&session_path);
304 let stored_cwd = session_cwd(&records).unwrap_or_else(|| cwd.to_path_buf());
305 if stored_cwd != validate_and_normalize_cwd(cwd, None).map_err(|error| error.to_string())? {
306 return Err(format!(
307 "session `{session_id}` belongs to cwd `{}`",
308 stored_cwd.display()
309 ));
310 }
311 let writer = SessionWriter::resume(&session_path, session_id).map_err(|error| error.to_string())?;
312 self.attach_loaded_session(session_id, &stored_cwd, Some(writer))?;
313 Ok(records)
314 }
315
316 fn resume_session(&self, session_id: &str, cwd: &Path) -> Result<(), String> {
317 let session_path = self.persisted_session_path(session_id)?;
318 let records = SessionReader::read_records(&session_path);
319 let stored_cwd = session_cwd(&records).unwrap_or_else(|| cwd.to_path_buf());
320 if stored_cwd != validate_and_normalize_cwd(cwd, None).map_err(|error| error.to_string())? {
321 return Err(format!(
322 "session `{session_id}` belongs to cwd `{}`",
323 stored_cwd.display()
324 ));
325 }
326 let writer = SessionWriter::resume(&session_path, session_id).map_err(|error| error.to_string())?;
327 self.attach_loaded_session(session_id, &stored_cwd, Some(writer))?;
328 Ok(())
329 }
330
331 fn attach_loaded_session(&self, session_id: &str, cwd: &Path, writer: Option<SessionWriter>) -> Result<(), String> {
332 let mut inner = self
333 .inner
334 .lock()
335 .map_err(|_| String::from("ACP server state lock poisoned"))?;
336 if inner.sessions.session(session_id).is_some() {
337 return Ok(());
338 }
339 inner
340 .sessions
341 .attach_existing_session(session_id, session_id, cwd, writer)
342 .map_err(|error| error.to_string())?;
343 inner
344 .sessions
345 .update_session_metadata(
346 session_id,
347 Some(self.config.model.clone()),
348 websearch_mode(&self.config.websearch),
349 )
350 .map_err(|error| error.to_string())?;
351 inner
352 .sessions
353 .update_session_reasoning(
354 session_id,
355 Some(self.config.reasoning_effort),
356 Some(self.config.reasoning_summary),
357 )
358 .map_err(|error| error.to_string())?;
359 Ok(())
360 }
361
362 fn persisted_session_path(&self, session_id: &str) -> Result<PathBuf, String> {
363 let Some(session_dir) = &self.config.session_dir else {
364 return Err(String::from("session persistence is not configured"));
365 };
366 let path = session_dir.join(format!("{session_id}.jsonl"));
367 if path.exists() { Ok(path) } else { Err(format!("unknown persisted session `{session_id}`")) }
368 }
369
370 fn close_session(&self, session_id: &str) -> Result<(), String> {
371 self.cancel_session(session_id);
372 let mut inner = self
373 .inner
374 .lock()
375 .map_err(|_| String::from("ACP server state lock poisoned"))?;
376 inner
377 .sessions
378 .remove_session(session_id)
379 .map(|_| ())
380 .ok_or_else(|| format!("missing session: {session_id}"))
381 }
382
383 fn delete_session(&self, session_id: &str) -> Result<(), String> {
384 self.cancel_session(session_id);
385 if let Ok(mut inner) = self.inner.lock() {
386 inner.sessions.remove_session(session_id);
387 }
388
389 if let Some(session_dir) = &self.config.session_dir {
390 let path = session_dir.join(format!("{session_id}.jsonl"));
391 if path.exists() {
392 return Err(String::from(
393 "session/delete is non-destructive until the persisted deletion policy is decided",
394 ));
395 }
396 }
397 Ok(())
398 }
399
400 fn begin_turn(&self, session_id: &str) -> Result<PromptTurnGuard, String> {
401 {
402 let mut inner = self
403 .inner
404 .lock()
405 .map_err(|_| String::from("ACP server state lock poisoned"))?;
406 inner.cancelled.remove(session_id);
407 inner
408 .sessions
409 .begin_turn(session_id)
410 .map_err(|error| error.to_string())?;
411 }
412
413 Ok(PromptTurnGuard { session_id: session_id.to_string(), state: self.clone() })
414 }
415
416 fn end_turn(&self, session_id: &str) -> Result<(), String> {
417 let mut inner = self
418 .inner
419 .lock()
420 .map_err(|_| String::from("ACP server state lock poisoned"))?;
421 inner.sessions.end_turn(session_id).map_err(|error| error.to_string())
422 }
423
424 pub fn cancel_session(&self, session_id: &str) {
426 if let Ok(mut inner) = self.inner.lock() {
427 if !inner.sessions.is_turn_active(session_id) {
428 return;
429 }
430 let first_cancel = inner.cancelled.insert(session_id.to_string());
431 if let Some(token) = inner.active_turn_cancels.remove(session_id) {
432 token.cancel();
433 }
434 if first_cancel && let Some(writer) = inner.sessions.session_writer_mut(session_id) {
435 let _ = writer.append(SessionRecord::Cancelled {
436 schema_version: SCHEMA_VERSION,
437 seq: 0,
438 time: crate::utils::datetime::now_iso8601(),
439 turn_id: String::from("acp-active-turn"),
440 reason: String::from("cancelled by ACP client"),
441 });
442 }
443 }
444 }
445
446 pub fn register_turn_cancel_token(&self, session_id: &str, token: CancelToken) {
448 if let Ok(mut inner) = self.inner.lock() {
449 inner.active_turn_cancels.insert(session_id.to_string(), token);
450 }
451 }
452
453 pub fn clear_turn_cancel_token(&self, session_id: &str) {
455 if let Ok(mut inner) = self.inner.lock() {
456 inner.active_turn_cancels.remove(session_id);
457 inner.cancelled.remove(session_id);
458 }
459 }
460
461 pub fn has_session(&self, session_id: &str) -> bool {
463 self.inner
464 .lock()
465 .map(|inner| inner.sessions.session(session_id).is_some())
466 .unwrap_or(false)
467 }
468
469 fn is_cancelled(&self, session_id: &str) -> bool {
470 self.inner
471 .lock()
472 .map(|inner| inner.cancelled.contains(session_id))
473 .unwrap_or(false)
474 }
475}
476
477#[derive(Debug)]
478struct PersistedTurn {
479 turn_id: String,
480 assistant: String,
481 reasoning: String,
482 finished: bool,
483}
484
485impl PersistedTurn {
486 fn new(turn_id: String) -> Self {
487 Self { turn_id, assistant: String::new(), reasoning: String::new(), finished: false }
488 }
489
490 fn record_event(&mut self, state: &ServerState, session_id: &str, event: &AgentEvent) {
491 match event {
492 AgentEvent::AssistantDelta(text) => self.assistant.push_str(text),
493 AgentEvent::ReasoningDelta(text) => self.reasoning.push_str(text),
494 AgentEvent::Usage { input_tokens, output_tokens } => {
495 state.append_record(
496 session_id,
497 SessionRecord::Usage {
498 schema_version: SCHEMA_VERSION,
499 seq: 0,
500 time: crate::utils::datetime::now_iso8601(),
501 input_tokens: *input_tokens,
502 output_tokens: *output_tokens,
503 },
504 );
505 }
506 AgentEvent::RequestAccounting(accounting) => {
507 state.append_record(
508 session_id,
509 SessionRecord::RequestAccounting {
510 schema_version: SCHEMA_VERSION,
511 seq: 0,
512 time: crate::utils::datetime::now_iso8601(),
513 turn_id: accounting.turn_id.clone(),
514 accounting: accounting.as_ref().clone(),
515 },
516 );
517 }
518 AgentEvent::ToolStarted { id, name, arguments } => {
519 state.append_record(
520 session_id,
521 SessionRecord::ToolStarted {
522 schema_version: SCHEMA_VERSION,
523 seq: 0,
524 time: crate::utils::datetime::now_iso8601(),
525 turn_id: self.turn_id.clone(),
526 call_id: id.clone(),
527 name: name.clone(),
528 arguments: sanitize_tool_payload(arguments),
529 mcp: None,
530 },
531 );
532 }
533 AgentEvent::ToolFinished { id, output, status, write_result, shell_result } => {
534 state.append_record(
535 session_id,
536 SessionRecord::ToolFinished {
537 schema_version: SCHEMA_VERSION,
538 seq: 0,
539 time: crate::utils::datetime::now_iso8601(),
540 turn_id: self.turn_id.clone(),
541 call_id: id.clone(),
542 status: *status,
543 output: crate::artifacts::bounded_redacted_lines(
544 output,
545 crate::artifacts::DEFAULT_MAX_ARTIFACT_BYTES,
546 ),
547 artifact: None,
548 mcp: None,
549 },
550 );
551 if let Some(result) = write_result {
552 state.append_record(
553 session_id,
554 SessionRecord::FileWrite {
555 schema_version: SCHEMA_VERSION,
556 seq: 0,
557 time: crate::utils::datetime::now_iso8601(),
558 turn_id: self.turn_id.clone(),
559 op: result.op,
560 path: result.path.display().to_string(),
561 before_hash: result.before_hash,
562 before_bytes: result.before_bytes,
563 after_hash: result.after_hash,
564 after_bytes: result.after_bytes,
565 status: *status,
566 },
567 );
568 }
569 if let Some(result) = shell_result {
570 state.append_record(
571 session_id,
572 SessionRecord::ShellExec {
573 schema_version: SCHEMA_VERSION,
574 seq: 0,
575 time: crate::utils::datetime::now_iso8601(),
576 turn_id: self.turn_id.clone(),
577 process_id: result.process_id,
578 command: crate::tools::shell::redact_secrets(&result.command.join(" ")),
579 cwd: result.cwd.display().to_string(),
580 process_status: result.status.label().to_string(),
581 exit_code: result.exit_code,
582 elapsed_ms: result.elapsed.as_millis() as u64,
583 kind: result.kind.label().to_string(),
584 },
585 );
586 }
587 }
588 AgentEvent::Cancelled => {
589 state.append_record(
590 session_id,
591 SessionRecord::Cancelled {
592 schema_version: SCHEMA_VERSION,
593 seq: 0,
594 time: crate::utils::datetime::now_iso8601(),
595 turn_id: self.turn_id.clone(),
596 reason: String::from("cancelled by ACP client"),
597 },
598 );
599 }
600 AgentEvent::Failed(error) => {
601 state.append_record(
602 session_id,
603 SessionRecord::Failed {
604 schema_version: SCHEMA_VERSION,
605 seq: 0,
606 time: crate::utils::datetime::now_iso8601(),
607 turn_id: self.turn_id.clone(),
608 error: error.clone(),
609 },
610 );
611 }
612 _ => {}
613 }
614 }
615
616 fn finish(&mut self, state: &ServerState, session_id: &str) {
617 if self.finished {
618 return;
619 }
620 self.finished = true;
621
622 if !self.reasoning.is_empty() {
623 state.append_record(
624 session_id,
625 SessionRecord::ReasoningFinished {
626 schema_version: SCHEMA_VERSION,
627 seq: 0,
628 time: crate::utils::datetime::now_iso8601(),
629 turn_id: self.turn_id.clone(),
630 text: self.reasoning.clone(),
631 },
632 );
633 }
634 if !self.assistant.is_empty() {
635 state.append_record(
636 session_id,
637 SessionRecord::AssistantFinished {
638 schema_version: SCHEMA_VERSION,
639 seq: 0,
640 time: crate::utils::datetime::now_iso8601(),
641 turn_id: self.turn_id.clone(),
642 text: self.assistant.clone(),
643 },
644 );
645 }
646 }
647}
648
649struct PromptTurnGuard {
650 session_id: String,
651 state: ServerState,
652}
653
654impl Drop for PromptTurnGuard {
655 fn drop(&mut self) {
656 let _ = self.state.end_turn(&self.session_id);
657 self.state.clear_turn_cancel_token(&self.session_id);
658 }
659}
660
661pub async fn run_stdio(config: ServerConfig) -> Result<()> {
663 let state = ServerState::new(config);
664 let initialize_state = state.clone();
665 let new_session_state = state.clone();
666 let list_session_state = state.clone();
667 let load_session_state = state.clone();
668 let resume_session_state = state.clone();
669 let close_session_state = state.clone();
670 let delete_session_state = state.clone();
671 let prompt_state = state.clone();
672 let config_option_state = state.clone();
673 let cancel_state = state;
674 let (eof_tx, eof_rx) = oneshot::channel();
675 let transport = Lines::new(
676 stdout_line_sink(tokio::io::stdout().compat_write()),
677 eof_signaling_lines(tokio::io::stdin().compat(), eof_tx),
678 );
679
680 let connection = Agent
681 .builder()
682 .name("thndrs")
683 .on_receive_request(
684 async move |request: InitializeRequest, responder, _connection| {
685 let response = initialize(&initialize_state, &request);
686 responder.respond(response)
687 },
688 agent_client_protocol::on_receive_request!(),
689 )
690 .on_receive_request(
691 async move |request: NewSessionRequest, responder, _connection| match new_session(
692 &new_session_state,
693 &request,
694 ) {
695 Ok(response) => responder.respond(response),
696 Err(error) => responder.respond_with_error(Error::invalid_params().data(error)),
697 },
698 agent_client_protocol::on_receive_request!(),
699 )
700 .on_receive_request(
701 async move |request: ListSessionsRequest, responder, _connection| match list_sessions(
702 &list_session_state,
703 &request,
704 ) {
705 Ok(response) => responder.respond(response),
706 Err(error) => responder.respond_with_error(Error::invalid_params().data(error)),
707 },
708 agent_client_protocol::on_receive_request!(),
709 )
710 .on_receive_request(
711 async move |request: LoadSessionRequest, responder, connection: ConnectionTo<Client>| match load_session(
712 &load_session_state,
713 &request,
714 &connection,
715 ) {
716 Ok(response) => responder.respond(response),
717 Err(error) => responder.respond_with_error(Error::invalid_params().data(error)),
718 },
719 agent_client_protocol::on_receive_request!(),
720 )
721 .on_receive_request(
722 async move |request: ResumeSessionRequest, responder, _connection| match resume_session(
723 &resume_session_state,
724 &request,
725 ) {
726 Ok(response) => responder.respond(response),
727 Err(error) => responder.respond_with_error(Error::invalid_params().data(error)),
728 },
729 agent_client_protocol::on_receive_request!(),
730 )
731 .on_receive_request(
732 async move |request: CloseSessionRequest, responder, _connection| match close_session(
733 &close_session_state,
734 &request,
735 ) {
736 Ok(response) => responder.respond(response),
737 Err(error) => responder.respond_with_error(Error::invalid_params().data(error)),
738 },
739 agent_client_protocol::on_receive_request!(),
740 )
741 .on_receive_request(
742 async move |request: DeleteSessionRequest, responder, _connection| match delete_session(
743 &delete_session_state,
744 &request,
745 ) {
746 Ok(response) => responder.respond(response),
747 Err(error) => responder.respond_with_error(Error::invalid_params().data(error)),
748 },
749 agent_client_protocol::on_receive_request!(),
750 )
751 .on_receive_request(
752 async move |request: SetSessionConfigOptionRequest, responder, _connection| match set_config_option(
753 &config_option_state,
754 &request,
755 ) {
756 Ok(response) => responder.respond(response),
757 Err(error) => responder.respond_with_error(Error::invalid_params().data(error)),
758 },
759 agent_client_protocol::on_receive_request!(),
760 )
761 .on_receive_request(
762 async move |request: PromptRequest, responder, connection: ConnectionTo<Client>| {
763 let state = prompt_state.clone();
764 let prompt_connection = connection.clone();
765 connection.spawn(async move {
766 match tokio::task::spawn_blocking(move || prompt(&state, &request, &prompt_connection)).await {
767 Ok(Ok(response)) => responder.respond(response),
768 Ok(Err(error)) => responder.respond_with_error(Error::invalid_params().data(error)),
769 Err(error) => responder.respond_with_error(
770 Error::internal_error().data(format!("ACP prompt task failed: {error}")),
771 ),
772 }
773 })
774 },
775 agent_client_protocol::on_receive_request!(),
776 )
777 .on_receive_notification(
778 async move |notification: CancelNotification, _connection| {
779 cancel_state.cancel_session(notification.session_id.0.as_ref());
780 Ok(())
781 },
782 agent_client_protocol::on_receive_notification!(),
783 )
784 .on_receive_dispatch(
785 async move |message: Dispatch, cx: ConnectionTo<Client>| {
786 message.respond_with_error(Error::method_not_found().data("unhandled ACP method"), cx)
787 },
788 agent_client_protocol::on_receive_dispatch!(),
789 )
790 .connect_to(transport);
791
792 match select(Box::pin(connection), Box::pin(eof_rx.map(|_| Ok(())))).await {
793 Either::Left((result, _)) | Either::Right((result, _)) => result,
794 }
795}
796
797pub fn initialize(state: &ServerState, request: &InitializeRequest) -> InitializeResponse {
798 state.record_client_info(request);
799 InitializeResponse::new(negotiate_protocol_version(request.protocol_version))
800 .agent_info(Some(
801 Implementation::new("thndrs", env!("CARGO_PKG_VERSION")).title(Some(String::from("thndrs"))),
802 ))
803 .agent_capabilities(capabilities(state))
804}
805
806pub fn set_config_option(
807 state: &ServerState, request: &SetSessionConfigOptionRequest,
808) -> Result<SetSessionConfigOptionResponse, String> {
809 let session_id = request.session_id.0.as_ref();
810 let option_id = request.config_id.0.as_ref();
811 let value = request.value.to_string();
812 let option = validate_config_option(option_id, &value).map_err(|error| error.to_string())?;
813 state.set_config_option(session_id, &option)?;
814 Ok(SetSessionConfigOptionResponse::new(
815 state.config_options_for_session(session_id)?,
816 ))
817}
818
819pub fn acp_mcp_config(servers: &[McpServer]) -> Result<McpConfig, String> {
820 let mut config = McpConfig::default();
821 for server in servers {
822 match server {
823 McpServer::Stdio(server) => {
824 crate::mcp::config::validate_mcp_server_name(&server.name).map_err(|error| error.to_string())?;
825 if server.command.as_os_str().is_empty() {
826 return Err(format!("MCP server `{}` has an empty stdio command", server.name));
827 }
828 config.servers.insert(
829 server.name.clone(),
830 McpServerConfig {
831 transport: McpTransport::Stdio,
832 command: server.command.display().to_string(),
833 args: server.args.clone(),
834 env: server
835 .env
836 .iter()
837 .map(|entry| (entry.name.clone(), entry.value.clone()))
838 .collect(),
839 ..McpServerConfig::default()
840 },
841 );
842 }
843 McpServer::Http(server) => {
844 return Err(format!(
845 "MCP server `{}` uses unsupported transport `http` in session/new",
846 server.name
847 ));
848 }
849 McpServer::Sse(server) => {
850 return Err(format!(
851 "MCP server `{}` uses unsupported transport `sse` in session/new",
852 server.name
853 ));
854 }
855 _ => return Err(String::from("MCP server uses unsupported transport in session/new")),
856 }
857 }
858 Ok(config)
859}
860
861pub fn execute_prompt(
862 state: &ServerState, request: &PromptRequest, mut on_update: impl FnMut(SessionUpdateIntent) -> Result<(), String>,
863 run_harness: impl FnOnce(AgentRunConfig, Vec<ProviderMessage>, bool, String) -> HarnessHandle,
864) -> Result<PromptResponse, String> {
865 let session_id = request.session_id.0.to_string();
866 let prompt = assemble_prompt(&request.prompt)?;
867
868 if state.is_cancelled(&session_id) {
869 return Ok(PromptResponse::new(StopReason::Cancelled));
870 }
871
872 let turn_guard = state.begin_turn(&session_id)?;
873
874 state.append_record(
875 &session_id,
876 SessionRecord::User {
877 schema_version: SCHEMA_VERSION,
878 seq: 0,
879 time: crate::utils::datetime::now_iso8601(),
880 turn_id: format!("{session_id}-turn"),
881 text: prompt.text.clone(),
882 },
883 );
884
885 let response = run_prompt_turn(prompt, state, &session_id, &mut on_update, run_harness)?;
886
887 drop(turn_guard);
888
889 Ok(response)
890}
891
892pub async fn client_read_text_file(
894 state: &ServerState, connection: &ConnectionTo<Client>, session_id: agent_client_protocol::schema::v1::SessionId,
895 path: PathBuf, line: Option<u32>, limit: Option<u32>,
896) -> Result<Option<String>, String> {
897 if !state.client_can_read_text_files() {
898 return Ok(None);
899 }
900 let request = ReadTextFileRequest::new(session_id, path).line(line).limit(limit);
901 connection
902 .send_request(request)
903 .block_task()
904 .await
905 .map(|response| Some(response.content))
906 .map_err(|error| format!("client fs/read_text_file failed: {error}"))
907}
908
909pub async fn client_write_text_file(
911 state: &ServerState, connection: &ConnectionTo<Client>, session_id: agent_client_protocol::schema::v1::SessionId,
912 path: PathBuf, content: String,
913) -> Result<bool, String> {
914 if !state.client_can_write_text_files() {
915 return Ok(false);
916 }
917 connection
918 .send_request(WriteTextFileRequest::new(session_id, path, content))
919 .block_task()
920 .await
921 .map(|_| true)
922 .map_err(|error| format!("client fs/write_text_file failed: {error}"))
923}
924
925fn new_session(state: &ServerState, request: &NewSessionRequest) -> Result<NewSessionResponse, String> {
926 if !request.cwd.is_absolute() {
927 return Err(format!("ACP session cwd must be absolute: {}", request.cwd.display()));
928 }
929 let cwd = validate_and_normalize_cwd(&request.cwd, None).map_err(|err| err.to_string())?;
930 let mcp_config = acp_mcp_config(&request.mcp_servers)?;
931 let session_id = state.create_session(&cwd)?;
932 if !mcp_config.servers.is_empty() {
933 state.attach_mcp_config(&session_id, mcp_config)?;
934 }
935 let config_options = state.config_options_for_session(&session_id)?;
936 Ok(NewSessionResponse::new(session_id).config_options(config_options))
937}
938
939fn list_sessions(state: &ServerState, request: &ListSessionsRequest) -> Result<ListSessionsResponse, String> {
940 if request.cursor.is_some() {
941 return Ok(ListSessionsResponse::new(Vec::new()));
942 }
943 if let Some(cwd) = &request.cwd
944 && !cwd.is_absolute()
945 {
946 return Err(format!("ACP session list cwd must be absolute: {}", cwd.display()));
947 }
948 Ok(ListSessionsResponse::new(state.list_sessions(request.cwd.as_deref())?))
949}
950
951fn load_session(
952 state: &ServerState, request: &LoadSessionRequest, connection: &ConnectionTo<Client>,
953) -> Result<LoadSessionResponse, String> {
954 if !request.cwd.is_absolute() {
955 return Err(format!(
956 "ACP session load cwd must be absolute: {}",
957 request.cwd.display()
958 ));
959 }
960 let session_id = request.session_id.0.as_ref();
961 let records = state.load_session(session_id, &request.cwd)?;
962 for record in records {
963 if let Some(update) = replay_record_update(&record) {
964 send_update(connection, request.session_id.clone(), update)?;
965 }
966 }
967 Ok(LoadSessionResponse::new().config_options(state.config_options_for_session(session_id)?))
968}
969
970fn resume_session(state: &ServerState, request: &ResumeSessionRequest) -> Result<ResumeSessionResponse, String> {
971 if !request.cwd.is_absolute() {
972 return Err(format!(
973 "ACP session resume cwd must be absolute: {}",
974 request.cwd.display()
975 ));
976 }
977 let session_id = request.session_id.0.as_ref();
978 state.resume_session(session_id, &request.cwd)?;
979 Ok(ResumeSessionResponse::new().config_options(state.config_options_for_session(session_id)?))
980}
981
982fn close_session(state: &ServerState, request: &CloseSessionRequest) -> Result<CloseSessionResponse, String> {
983 state.close_session(request.session_id.0.as_ref())?;
984 Ok(CloseSessionResponse::new())
985}
986
987fn delete_session(state: &ServerState, request: &DeleteSessionRequest) -> Result<DeleteSessionResponse, String> {
988 state.delete_session(request.session_id.0.as_ref())?;
989 Ok(DeleteSessionResponse::new())
990}
991
992fn prompt(
993 state: &ServerState, request: &PromptRequest, connection: &ConnectionTo<Client>,
994) -> Result<PromptResponse, String> {
995 let session_id = request.session_id.clone();
996 execute_prompt(
997 state,
998 request,
999 move |intent| send_update(connection, session_id.clone(), intent),
1000 {
1001 let connection = connection.clone();
1002 move |config, messages, expects_write, _prompt_text| {
1003 let permission_hook = server_permission_hook(
1004 state.clone(),
1005 request.session_id.clone(),
1006 format!("{}-turn", request.session_id.0.as_ref()),
1007 connection.clone(),
1008 );
1009 let execution_hook = server_execution_hook(state.clone(), request.session_id.clone(), connection);
1010 let (_steering_tx, steering_rx) = mpsc::channel();
1011 drop(_steering_tx);
1012 crate::harness::HarnessTurn::provider_with_steering_permissions_and_execution(
1013 config,
1014 messages,
1015 expects_write,
1016 steering_rx,
1017 permission_hook,
1018 execution_hook,
1019 )
1020 .start()
1021 }
1022 },
1023 )
1024}
1025
1026fn server_execution_hook(
1027 state: ServerState, session_id: agent_client_protocol::schema::v1::SessionId, connection: ConnectionTo<Client>,
1028) -> ToolExecutionHook {
1029 ToolExecutionHook::new(move |request, config, cancel| {
1030 if request.name != "run_shell" || !state.client_can_run_terminal() {
1031 return None;
1032 }
1033 Some(execute_shell_in_client_terminal(
1034 &connection,
1035 session_id.clone(),
1036 request,
1037 config,
1038 cancel,
1039 ))
1040 })
1041}
1042
1043fn server_permission_hook(
1044 state: ServerState, session_id: agent_client_protocol::schema::v1::SessionId, turn_id: String,
1045 connection: ConnectionTo<Client>,
1046) -> ToolPermissionHook {
1047 ToolPermissionHook::new(move |request, _config, cancel| {
1048 request_client_permission(&state, &connection, session_id.clone(), &turn_id, request, cancel)
1049 })
1050}
1051
1052fn request_client_permission(
1053 state: &ServerState, connection: &ConnectionTo<Client>, session_id: agent_client_protocol::schema::v1::SessionId,
1054 turn_id: &str, request: &ToolUseRequest, cancel: &CancelToken,
1055) -> ToolPermissionDecision {
1056 if cancel.is_cancelled() {
1057 return ToolPermissionDecision::Cancelled;
1058 }
1059
1060 let options = vec![
1061 PermissionOption::new("allow_once", "Allow once", PermissionOptionKind::AllowOnce),
1062 PermissionOption::new("reject_once", "Reject once", PermissionOptionKind::RejectOnce),
1063 ];
1064 let title = permission_title(request);
1065 let session_id_text = session_id.0.to_string();
1066 state.append_record(
1067 &session_id_text,
1068 SessionRecord::AcpPermissionRequest {
1069 schema_version: SCHEMA_VERSION,
1070 seq: 0,
1071 time: crate::utils::datetime::now_iso8601(),
1072 turn_id: turn_id.to_string(),
1073 tool_call_id: request.tool_use_id.clone(),
1074 title: title.clone(),
1075 options: options
1076 .iter()
1077 .map(|option| AcpPermissionOptionRecord {
1078 id: option.option_id.0.to_string(),
1079 name: option.name.clone(),
1080 kind: match &option.kind {
1081 PermissionOptionKind::AllowOnce => "allow_once",
1082 PermissionOptionKind::AllowAlways => "allow_always",
1083 PermissionOptionKind::RejectOnce => "reject_once",
1084 PermissionOptionKind::RejectAlways => "reject_always",
1085 _ => "other",
1086 }
1087 .to_string(),
1088 })
1089 .collect(),
1090 },
1091 );
1092
1093 let permission = RequestPermissionRequest::new(
1094 session_id,
1095 ToolCallUpdate::new(
1096 request.tool_use_id.clone(),
1097 ToolCallUpdateFields::new()
1098 .title(title)
1099 .kind(classify_tool(&request.name).to_acp_kind())
1100 .status(ToolCallStatus::InProgress)
1101 .raw_input(json_text_or_value(sanitize_tool_payload(&request.arguments))),
1102 ),
1103 options,
1104 );
1105 let (tx, rx) = mpsc::channel();
1106 if connection
1107 .send_request(permission)
1108 .on_receiving_result(async move |result| {
1109 let _ = tx.send(result);
1110 Ok(())
1111 })
1112 .is_err()
1113 {
1114 return ToolPermissionDecision::Reject;
1115 }
1116
1117 loop {
1118 if cancel.is_cancelled() {
1119 return ToolPermissionDecision::Cancelled;
1120 }
1121 match rx.recv_timeout(PERMISSION_POLL_INTERVAL) {
1122 Ok(Ok(response)) => {
1123 let decision = permission_response_decision(response.outcome);
1124 state.append_record(
1125 &session_id_text,
1126 SessionRecord::AcpPermissionOutcome {
1127 schema_version: SCHEMA_VERSION,
1128 seq: 0,
1129 time: crate::utils::datetime::now_iso8601(),
1130 turn_id: turn_id.to_string(),
1131 tool_call_id: request.tool_use_id.clone(),
1132 outcome: decision.outcome_label().to_string(),
1133 },
1134 );
1135 return decision;
1136 }
1137 Ok(Err(_)) | Err(mpsc::RecvTimeoutError::Disconnected) => {
1138 state.append_record(
1139 &session_id_text,
1140 SessionRecord::AcpPermissionOutcome {
1141 schema_version: SCHEMA_VERSION,
1142 seq: 0,
1143 time: crate::utils::datetime::now_iso8601(),
1144 turn_id: turn_id.to_string(),
1145 tool_call_id: request.tool_use_id.clone(),
1146 outcome: "rejected".to_string(),
1147 },
1148 );
1149 return ToolPermissionDecision::Reject;
1150 }
1151 Err(mpsc::RecvTimeoutError::Timeout) => {}
1152 }
1153 }
1154}
1155
1156fn execute_shell_in_client_terminal(
1157 connection: &ConnectionTo<Client>, session_id: agent_client_protocol::schema::v1::SessionId,
1158 request: &ToolUseRequest, config: &AgentRunConfig, cancel: &CancelToken,
1159) -> (ToolOutput, Option<crate::tools::WriteResult>, Option<ProcessResult>) {
1160 let args = match crate::tools::shell::parse_arguments(&request.arguments) {
1161 Ok(args) if !args.program.trim().is_empty() => args,
1162 Ok(_) => return failed_shell_execution("missing or empty 'program' field"),
1163 Err(error) => return failed_shell_execution(&error.to_string()),
1164 };
1165 let cwd = match client_terminal_cwd(&config.root, &args.cwd) {
1166 Ok(cwd) => cwd,
1167 Err(error) => return failed_shell_execution(&error),
1168 };
1169
1170 let create = CreateTerminalRequest::new(session_id.clone(), args.program.clone())
1171 .args(args.args.clone())
1172 .cwd(Some(cwd.clone()))
1173 .output_byte_limit(Some(MAX_OUTPUT_BYTES as u64));
1174 let create_response = match block_client_request(connection, create) {
1175 Ok(response) => response,
1176 Err(error) => return failed_shell_execution(&format!("client terminal/create failed: {error}")),
1177 };
1178
1179 let terminal_id = create_response.terminal_id;
1180 let start = Instant::now();
1181 let (mut final_output, exit_status) = loop {
1182 if cancel.is_cancelled() {
1183 let _ = block_client_request(
1184 connection,
1185 KillTerminalRequest::new(session_id.clone(), terminal_id.clone()),
1186 );
1187 let output = block_client_request(
1188 connection,
1189 TerminalOutputRequest::new(session_id.clone(), terminal_id.clone()),
1190 )
1191 .unwrap_or_else(|_| TerminalOutputResponse::new(String::new(), false));
1192 break (output, None);
1193 }
1194
1195 match block_client_request(
1196 connection,
1197 TerminalOutputRequest::new(session_id.clone(), terminal_id.clone()),
1198 ) {
1199 Ok(output) => {
1200 let status = output.exit_status.clone();
1201 if status.is_some() {
1202 break (output, status);
1203 }
1204 }
1205 Err(error) => {
1206 let _ = block_client_request(connection, ReleaseTerminalRequest::new(session_id, terminal_id));
1207 return failed_shell_execution(&format!("client terminal/output failed: {error}"));
1208 }
1209 }
1210
1211 std::thread::sleep(TERMINAL_POLL_INTERVAL);
1212 };
1213
1214 let wait_status = match exit_status {
1215 Some(status) => status,
1216 None => TerminalExitStatus::new(),
1217 };
1218 let wait = block_client_request(
1219 connection,
1220 WaitForTerminalExitRequest::new(session_id.clone(), terminal_id.clone()),
1221 )
1222 .ok()
1223 .map(|response| response.exit_status)
1224 .unwrap_or(wait_status);
1225 if let Ok(output) = block_client_request(
1226 connection,
1227 TerminalOutputRequest::new(session_id.clone(), terminal_id.clone()),
1228 ) {
1229 final_output = output;
1230 }
1231 let _ = block_client_request(connection, ReleaseTerminalRequest::new(session_id, terminal_id));
1232
1233 let result = process_result_from_terminal(&args, cwd, start.elapsed(), &final_output, &wait, cancel);
1234 let output = result.to_tool_output();
1235 (output, None, Some(result))
1236}
1237
1238fn block_client_request<R>(
1239 connection: &ConnectionTo<Client>, request: R,
1240) -> std::result::Result<R::Response, agent_client_protocol::Error>
1241where
1242 R: JsonRpcRequest,
1243{
1244 futures::executor::block_on(connection.send_request(request).block_task())
1245}
1246
1247fn failed_shell_execution(message: &str) -> (ToolOutput, Option<crate::tools::WriteResult>, Option<ProcessResult>) {
1248 (ToolOutput::failed("run_shell", message.to_string()), None, None)
1249}
1250
1251fn client_terminal_cwd(root: &Path, cwd: &Option<PathBuf>) -> Result<PathBuf, String> {
1252 let root = root
1253 .canonicalize()
1254 .map_err(|error| format!("invalid workspace root for terminal: {error}"))?;
1255 let Some(cwd) = cwd else {
1256 return Ok(root);
1257 };
1258 if cwd.is_absolute() {
1259 return Err(String::from("run_shell cwd must be relative to the workspace root"));
1260 }
1261 let resolved = crate::tools::resolve_workspace_path(&root, cwd).map_err(|error| error.to_string())?;
1262 if !resolved.is_dir() {
1263 return Err(format!("working directory is not a directory: {}", resolved.display()));
1264 }
1265 Ok(resolved)
1266}
1267
1268fn process_result_from_terminal(
1269 args: &ShellArgs, cwd: PathBuf, elapsed: Duration, output: &TerminalOutputResponse,
1270 exit_status: &TerminalExitStatus, cancel: &CancelToken,
1271) -> ProcessResult {
1272 let status = if cancel.is_cancelled() {
1273 ProcessStatus::Cancelled
1274 } else {
1275 match exit_status.exit_code {
1276 Some(0) => ProcessStatus::Ok,
1277 Some(_) => ProcessStatus::Failed,
1278 None => ProcessStatus::Failed,
1279 }
1280 };
1281 let mut stdout = output.output.lines().map(redact_secrets).collect::<Vec<_>>();
1282 if output.truncated {
1283 stdout.insert(0, "[terminal output truncated]".to_string());
1284 }
1285 ProcessResult {
1286 process_id: None,
1287 command: args.argv(),
1288 cwd,
1289 status,
1290 exit_code: exit_status.exit_code.and_then(|code| i32::try_from(code).ok()),
1291 stdout,
1292 stderr: Vec::new(),
1293 elapsed,
1294 kind: ProcessKind::OneShot,
1295 }
1296}
1297
1298fn permission_response_decision(outcome: RequestPermissionOutcome) -> ToolPermissionDecision {
1299 match outcome {
1300 RequestPermissionOutcome::Cancelled => ToolPermissionDecision::Cancelled,
1301 RequestPermissionOutcome::Selected(selected) => {
1302 let option_id = selected.option_id.0.as_ref();
1303 if option_id.starts_with("allow") {
1304 ToolPermissionDecision::Allow
1305 } else {
1306 ToolPermissionDecision::Reject
1307 }
1308 }
1309 _ => ToolPermissionDecision::Reject,
1310 }
1311}
1312
1313fn permission_title(request: &ToolUseRequest) -> String {
1314 match request.name.as_str() {
1315 "run_shell" => "Run shell command".to_string(),
1316 "create_file" => "Create file".to_string(),
1317 "replace_range" => "Edit file".to_string(),
1318 "write_patch" => "Apply patch".to_string(),
1319 _ => format!("Run {}", request.name),
1320 }
1321}
1322
1323fn run_prompt_turn(
1325 prompt: PromptAssembly, state: &ServerState, session_id: &str,
1326 on_update: &mut impl FnMut(SessionUpdateIntent) -> Result<(), String>,
1327 run_harness: impl FnOnce(AgentRunConfig, Vec<ProviderMessage>, bool, String) -> HarnessHandle,
1328) -> Result<PromptResponse, String> {
1329 let session = state.session(session_id)?;
1330 let websearch = session
1331 .metadata
1332 .websearch
1333 .or_else(|| websearch_mode(&state.config.websearch))
1334 .unwrap_or(WebSearchMode::DuckDuckGo);
1335 let model = session.metadata.model.unwrap_or_else(|| state.config.model.clone());
1336
1337 let effort = session
1338 .metadata
1339 .reasoning_effort
1340 .unwrap_or(state.config.reasoning_effort);
1341 let summary = session
1342 .metadata
1343 .reasoning_summary
1344 .unwrap_or(state.config.reasoning_summary);
1345 let mut config = AgentRunConfig::new(session.cwd, model, websearch)
1346 .with_search_url(state.config.websearch_url.clone())
1347 .with_reasoning(effort, summary);
1348 if let Some(mcp_config) = session.mcp_config {
1349 config = config.with_mcp_manager(Arc::new(McpManager::from_config(&mcp_config)));
1350 }
1351 let bundle = prompt::PromptBundle::new(&config.root, &config.model, config.search_mode, &[], &[], "");
1352 let mut messages = crate::prompt::lower_to_umans_messages(&bundle);
1353 if !prompt.provider_blocks.is_empty() {
1354 messages.push(ProviderMessage::user_blocks(prompt.provider_blocks.clone()));
1355 }
1356 let expects_write = prompt_expects_workspace_write(&prompt.text);
1357 let handle = run_harness(config, messages, expects_write, prompt.text);
1358 state.register_turn_cancel_token(session_id, handle.cancel.clone());
1359 if state.is_cancelled(session_id) {
1360 handle.cancel.cancel();
1361 }
1362
1363 let response = run_prompt_handle(state, session_id, &handle, on_update)?;
1364 state.clear_turn_cancel_token(session_id);
1365 Ok(response)
1366}
1367
1368fn run_prompt_handle(
1369 state: &ServerState, session_id: &str, handle: &HarnessHandle,
1370 on_update: &mut impl FnMut(SessionUpdateIntent) -> Result<(), String>,
1371) -> Result<PromptResponse, String> {
1372 let mut persisted = PersistedTurn::new(format!("{session_id}-turn"));
1373 loop {
1374 match handle.events.recv() {
1375 Ok(event) => {
1376 persisted.record_event(state, session_id, &event);
1377 for intent in map_agent_event(&event) {
1378 on_update(intent)?;
1379 }
1380 if handle.cancel.is_cancelled() {
1381 persisted.finish(state, session_id);
1382 return Ok(PromptResponse::new(StopReason::Cancelled));
1383 }
1384
1385 match event {
1386 AgentEvent::Finished => {
1387 persisted.finish(state, session_id);
1388 return Ok(PromptResponse::new(StopReason::EndTurn));
1389 }
1390 AgentEvent::Cancelled => {
1391 persisted.finish(state, session_id);
1392 return Ok(PromptResponse::new(StopReason::Cancelled));
1393 }
1394 AgentEvent::Failed(_) => {
1395 persisted.finish(state, session_id);
1396 return Ok(PromptResponse::new(StopReason::Refusal));
1397 }
1398 _ => (),
1399 }
1400 }
1401 Err(_) if handle.cancel.is_cancelled() => {
1402 persisted.finish(state, session_id);
1403 return Ok(PromptResponse::new(StopReason::Cancelled));
1404 }
1405 Err(_) => return Err(String::from("prompt turn ended without a terminal event")),
1406 }
1407 }
1408}
1409
1410fn capabilities(_state: &ServerState) -> AgentCapabilities {
1411 AgentCapabilities::new()
1412 .load_session(true)
1413 .prompt_capabilities(PromptCapabilities::new().image(true).embedded_context(true))
1414 .mcp_capabilities(McpCapabilities::new())
1415 .session_capabilities(
1416 SessionCapabilities::new()
1417 .list(SessionListCapabilities::new())
1418 .resume(SessionResumeCapabilities::new())
1419 .close(SessionCloseCapabilities::new())
1420 .delete(SessionDeleteCapabilities::new()),
1421 )
1422}
1423
1424fn negotiate_protocol_version(requested: ProtocolVersion) -> ProtocolVersion {
1425 let _ = requested;
1426 ProtocolVersion::V1
1427}
1428
1429#[derive(Clone, Debug, PartialEq)]
1430struct PromptAssembly {
1431 text: String,
1432 provider_blocks: Vec<ProviderContentBlock>,
1433}
1434
1435fn assemble_prompt(blocks: &[ContentBlock]) -> Result<PromptAssembly, String> {
1436 let mut text_parts = Vec::new();
1437 let mut provider_blocks = Vec::new();
1438 for block in blocks {
1439 match block {
1440 ContentBlock::Text(content) => {
1441 text_parts.push(content.text.clone());
1442 provider_blocks.push(ProviderContentBlock::Text { text: content.text.clone() });
1443 }
1444 ContentBlock::Image(content) => {
1445 let marker = image_prompt_marker(&content.mime_type, content.uri.as_deref());
1446 text_parts.push(marker);
1447 provider_blocks.push(ProviderContentBlock::Image {
1448 source: ProviderImageSource::Base64 {
1449 media_type: content.mime_type.clone(),
1450 data: content.data.clone(),
1451 },
1452 });
1453 }
1454 ContentBlock::ResourceLink(link) => {
1455 let marker = resource_link_prompt_marker(link);
1456 text_parts.push(marker.clone());
1457 provider_blocks.push(ProviderContentBlock::Text { text: marker });
1458 }
1459 ContentBlock::Resource(resource) => {
1460 let marker = embedded_resource_prompt_marker(resource);
1461 text_parts.push(marker.clone());
1462 provider_blocks.push(ProviderContentBlock::Text { text: marker });
1463 }
1464 ContentBlock::Audio(_) => {
1465 return Err(String::from("unsupported ACP prompt content block: audio"));
1466 }
1467 other => {
1468 return Err(format!("unsupported ACP prompt content block: {other:?}"));
1469 }
1470 }
1471 }
1472 Ok(PromptAssembly { text: text_parts.join("\n"), provider_blocks })
1473}
1474
1475fn image_prompt_marker(mime_type: &str, uri: Option<&str>) -> String {
1476 match uri {
1477 Some(uri) => format!("[image: {mime_type}; uri={uri}]"),
1478 None => format!("[image: {mime_type}]"),
1479 }
1480}
1481
1482fn resource_link_prompt_marker(link: &ResourceLink) -> String {
1483 let mut details = vec![link.uri.clone()];
1484 if let Some(mime_type) = &link.mime_type {
1485 details.push(format!("mime={mime_type}"));
1486 }
1487 if let Some(description) = &link.description {
1488 details.push(format!("description={description}"));
1489 }
1490 format!("[resource link: {} ({})]", link.name, details.join(", "))
1491}
1492
1493fn embedded_resource_prompt_marker(resource: &EmbeddedResource) -> String {
1494 match &resource.resource {
1495 EmbeddedResourceResource::TextResourceContents(contents) => {
1496 let mime = contents.mime_type.as_deref().unwrap_or("text/plain");
1497 format!("[embedded resource: {} ({mime})]\n{}", contents.uri, contents.text)
1498 }
1499 EmbeddedResourceResource::BlobResourceContents(contents) => {
1500 let mime = contents.mime_type.as_deref().unwrap_or("application/octet-stream");
1501 format!(
1502 "[embedded resource: {} ({mime}); base64 bytes={}]",
1503 contents.uri,
1504 contents.blob.len()
1505 )
1506 }
1507 other => format!("[embedded resource: unsupported payload {other:?}]"),
1508 }
1509}
1510
1511fn send_update(
1512 connection: &ConnectionTo<Client>, session_id: agent_client_protocol::schema::v1::SessionId,
1513 intent: SessionUpdateIntent,
1514) -> Result<(), String> {
1515 match lower_update_intent(intent) {
1516 Some(update) => connection
1517 .send_notification(SessionNotification::new(session_id, update))
1518 .map_err(|err| format!("failed to send ACP session update: {err}")),
1519 None => Ok(()),
1520 }
1521}
1522
1523fn lower_update_intent(intent: SessionUpdateIntent) -> Option<SessionUpdate> {
1524 match intent {
1525 SessionUpdateIntent::AssistantDelta(text) => Some(SessionUpdate::AgentMessageChunk(ContentChunk::new(
1526 ContentBlock::Text(TextContent::new(text)),
1527 ))),
1528 SessionUpdateIntent::ReasoningDelta(text) => Some(SessionUpdate::AgentThoughtChunk(ContentChunk::new(
1529 ContentBlock::Text(TextContent::new(text)),
1530 ))),
1531 SessionUpdateIntent::Usage { input_tokens, output_tokens } => Some(SessionUpdate::UsageUpdate(
1532 UsageUpdate::new(input_tokens, input_tokens.saturating_add(output_tokens)),
1533 )),
1534 SessionUpdateIntent::Status(text) => Some(SessionUpdate::AgentMessageChunk(ContentChunk::new(
1535 ContentBlock::Text(TextContent::new(text)),
1536 ))),
1537 SessionUpdateIntent::ToolStarted { id, name, arguments, kind, locations } => Some(SessionUpdate::ToolCall(
1538 ToolCall::new(id, name)
1539 .status(ToolCallStatus::InProgress)
1540 .kind(kind.to_acp_kind())
1541 .locations(locations)
1542 .raw_input(json_text_or_value(arguments)),
1543 )),
1544 SessionUpdateIntent::ToolFinished { id, status, output, locations, .. } => {
1545 let output_text = output.join("\n");
1546 let fields = ToolCallUpdateFields::new()
1547 .status(tool_call_status(status))
1548 .raw_output(json_text_or_value(output_text.clone()))
1549 .locations(locations)
1550 .content(if output_text.is_empty() {
1551 None
1552 } else {
1553 Some(vec![ToolCallContent::Content(Content::new(ContentBlock::Text(
1554 TextContent::new(output_text),
1555 )))])
1556 });
1557 Some(SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(id, fields)))
1558 }
1559 SessionUpdateIntent::Failed(message) => Some(SessionUpdate::AgentMessageChunk(ContentChunk::new(
1560 ContentBlock::Text(TextContent::new(format!("prompt failed: {message}"))),
1561 ))),
1562 SessionUpdateIntent::Cancelled => Some(SessionUpdate::AgentMessageChunk(ContentChunk::new(
1563 ContentBlock::Text(TextContent::new("prompt was cancelled")),
1564 ))),
1565 SessionUpdateIntent::Finished => None,
1566 }
1567}
1568
1569fn replay_record_update(record: &SessionRecord) -> Option<SessionUpdateIntent> {
1570 match record {
1571 SessionRecord::User { text, .. } => Some(SessionUpdateIntent::Status(format!("user: {text}"))),
1572 SessionRecord::AssistantFinished { text, .. } => Some(SessionUpdateIntent::AssistantDelta(text.clone())),
1573 SessionRecord::ReasoningFinished { text, .. } => Some(SessionUpdateIntent::ReasoningDelta(text.clone())),
1574 SessionRecord::Usage { input_tokens, output_tokens, .. } => {
1575 Some(SessionUpdateIntent::Usage { input_tokens: *input_tokens, output_tokens: *output_tokens })
1576 }
1577 SessionRecord::ToolStarted { call_id, name, arguments, .. } => Some(SessionUpdateIntent::ToolStarted {
1578 id: call_id.clone(),
1579 name: name.clone(),
1580 arguments: sanitize_tool_payload(arguments),
1581 kind: classify_tool(name),
1582 locations: vec![],
1583 }),
1584 SessionRecord::ToolFinished { call_id, status, output, .. } => Some(SessionUpdateIntent::ToolFinished {
1585 id: call_id.clone(),
1586 status: (*status).into(),
1587 output: output.clone(),
1588 kind: crate::server::events::ToolCallKind::Other,
1589 locations: vec![],
1590 }),
1591 SessionRecord::Cancelled { .. } => Some(SessionUpdateIntent::Cancelled),
1592 SessionRecord::Failed { error, .. } => Some(SessionUpdateIntent::Failed(error.clone())),
1593 _ => None,
1594 }
1595}
1596
1597fn session_info_from_file(path: &Path, cwd_filter: Option<&Path>) -> Option<SessionInfo> {
1598 let records = SessionReader::read_records(path);
1599 let session_id = &records
1600 .iter()
1601 .find_map(|record| match record {
1602 SessionRecord::SessionMeta { session_id, .. } => Some(session_id.clone()),
1603 _ => None,
1604 })
1605 .or_else(|| path.file_stem().and_then(|stem| stem.to_str()).map(str::to_string))?;
1606 let cwd = session_cwd(&records)?;
1607 if cwd_filter.is_some_and(|expected| expected != cwd) {
1608 return None;
1609 }
1610
1611 let title = SessionReader::read_title(path);
1612 let updated_at = records.iter().rev().find_map(record_time);
1613 Some(
1614 SessionInfo::new(session_id.to_string(), cwd)
1615 .title(Some(title))
1616 .updated_at(updated_at),
1617 )
1618}
1619
1620fn session_cwd(records: &[SessionRecord]) -> Option<PathBuf> {
1621 records.iter().find_map(|record| match record {
1622 SessionRecord::SessionMeta { cwd, .. } => Some(PathBuf::from(cwd)),
1623 _ => None,
1624 })
1625}
1626
1627fn record_time(record: &SessionRecord) -> Option<String> {
1628 match record {
1629 SessionRecord::SessionMeta { time, .. }
1630 | SessionRecord::Context { time, .. }
1631 | SessionRecord::ContextLedger { time, .. }
1632 | SessionRecord::ContextPin { time, .. }
1633 | SessionRecord::ContextDrop { time, .. }
1634 | SessionRecord::ContextRecovery { time, .. }
1635 | SessionRecord::Compaction { time, .. }
1636 | SessionRecord::CompactionReview { time, .. }
1637 | SessionRecord::User { time, .. }
1638 | SessionRecord::PromptMetadata { time, .. }
1639 | SessionRecord::AssistantFinished { time, .. }
1640 | SessionRecord::ReasoningFinished { time, .. }
1641 | SessionRecord::Usage { time, .. }
1642 | SessionRecord::ToolStarted { time, .. }
1643 | SessionRecord::ToolFinished { time, .. }
1644 | SessionRecord::Cancelled { time, .. }
1645 | SessionRecord::Failed { time, .. }
1646 | SessionRecord::AcpSession { time, .. }
1647 | SessionRecord::SessionRenamed { time, .. }
1648 | SessionRecord::FileWrite { time, .. }
1649 | SessionRecord::McpConfigChanged { time, .. }
1650 | SessionRecord::ShellExec { time, .. }
1651 | SessionRecord::SkillActivated { time, .. }
1652 | SessionRecord::QueuedInput { time, .. }
1653 | SessionRecord::AcpPermissionRequest { time, .. }
1654 | SessionRecord::AcpPermissionOutcome { time, .. }
1655 | SessionRecord::RequestAccounting { time, .. } => Some(time.clone()),
1656 }
1657}
1658
1659fn tool_call_status(intent: ToolStatusIntent) -> ToolCallStatus {
1660 match intent {
1661 ToolStatusIntent::InProgress => ToolCallStatus::InProgress,
1662 ToolStatusIntent::Completed => ToolCallStatus::Completed,
1663 ToolStatusIntent::Failed => ToolCallStatus::Failed,
1664 }
1665}
1666
1667fn json_text_or_value(raw: String) -> serde_json::Value {
1668 serde_json::from_str(&raw).unwrap_or_else(|_| serde_json::Value::String(raw))
1669}
1670
1671fn websearch_mode(value: &str) -> Option<WebSearchMode> {
1672 match value {
1673 "duckduckgo" => Some(WebSearchMode::DuckDuckGo),
1674 "searxng" => Some(WebSearchMode::Searxng),
1675 "none" => Some(WebSearchMode::None),
1676 _ => None,
1677 }
1678}
1679
1680fn eof_signaling_lines<R>(
1681 reader: R, eof_tx: oneshot::Sender<()>,
1682) -> impl Stream<Item = std::io::Result<String>> + Send + 'static
1683where
1684 R: AsyncRead + Unpin + Send + 'static,
1685{
1686 let lines = futures::io::BufReader::new(reader).lines();
1687 futures::stream::unfold((lines, Some(eof_tx)), |(mut lines, mut eof_tx)| async move {
1688 match lines.next().await {
1689 Some(line) => Some((line, (lines, eof_tx))),
1690 None => {
1691 if let Some(tx) = eof_tx.take() {
1692 let _ = tx.send(());
1693 }
1694 None
1695 }
1696 }
1697 })
1698}
1699
1700fn stdout_line_sink<W>(writer: W) -> impl Sink<String, Error = std::io::Error> + Send + 'static
1701where
1702 W: AsyncWrite + Send + 'static,
1703{
1704 futures::sink::unfold(Box::pin(writer), async move |mut writer, line: String| {
1705 let mut bytes = line.into_bytes();
1706 bytes.push(b'\n');
1707 writer.write_all(&bytes).await?;
1708 Ok::<_, std::io::Error>(writer)
1709 })
1710}
1711
1712#[cfg(test)]
1713mod tests {
1714 use super::*;
1715 use std::path::PathBuf;
1716
1717 fn state() -> ServerState {
1718 ServerState::new(ServerConfig::new(
1719 PathBuf::from("/tmp/workspace"),
1720 String::from("umans-coder"),
1721 String::from("duckduckgo"),
1722 None,
1723 ))
1724 }
1725
1726 #[test]
1727 fn creates_opaque_session_ids() {
1728 let state = state();
1729 let workspace = tempfile::tempdir().expect("temp workspace");
1730
1731 let first = state.create_session(workspace.path()).expect("first session");
1732 let second = state.create_session(workspace.path()).expect("second session");
1733
1734 assert_eq!("acp-session-00000001", first);
1735 assert_eq!("acp-session-00000002", second);
1736 assert!(state.has_session(&first));
1737 assert!(state.has_session(&second));
1738 }
1739
1740 #[test]
1741 fn rejects_relative_session_cwd() {
1742 let state = state();
1743
1744 let err = state
1745 .create_session(Path::new("relative"))
1746 .expect_err("relative cwd rejected");
1747
1748 assert!(err.contains("must be absolute"));
1749 }
1750
1751 #[test]
1752 fn joins_text_prompt_blocks() {
1753 let prompt = assemble_prompt(&[
1754 ContentBlock::Text(TextContent::new("first")),
1755 ContentBlock::Text(TextContent::new("second")),
1756 ])
1757 .expect("text prompt");
1758
1759 assert_eq!("first\nsecond", prompt.text);
1760 assert_eq!(
1761 prompt.provider_blocks,
1762 vec![
1763 ProviderContentBlock::Text { text: "first".to_string() },
1764 ProviderContentBlock::Text { text: "second".to_string() },
1765 ]
1766 );
1767 }
1768
1769 #[test]
1770 fn assembles_rich_prompt_blocks() {
1771 let prompt = assemble_prompt(&[
1772 ContentBlock::Text(TextContent::new("look at this")),
1773 ContentBlock::Image(ImageContent::new("aGVsbG8=", "image/png").uri("file:///tmp/image.png")),
1774 ContentBlock::ResourceLink(
1775 ResourceLink::new("notes.md", "file:///tmp/notes.md").mime_type("text/markdown"),
1776 ),
1777 ContentBlock::Resource(EmbeddedResource::new(EmbeddedResourceResource::TextResourceContents(
1778 TextResourceContents::new("embedded notes", "file:///tmp/embedded.md").mime_type("text/markdown"),
1779 ))),
1780 ])
1781 .expect("rich prompt");
1782
1783 assert!(prompt.text.contains("look at this"));
1784 assert!(prompt.text.contains("[image: image/png; uri=file:///tmp/image.png]"));
1785 assert!(
1786 prompt
1787 .text
1788 .contains("[resource link: notes.md (file:///tmp/notes.md, mime=text/markdown)]")
1789 );
1790 assert!(
1791 prompt
1792 .text
1793 .contains("[embedded resource: file:///tmp/embedded.md (text/markdown)]")
1794 );
1795 assert!(
1796 prompt
1797 .provider_blocks
1798 .iter()
1799 .any(|block| matches!(block, ProviderContentBlock::Image { .. }))
1800 );
1801 }
1802
1803 #[test]
1804 fn initializes_with_supported_protocol_version() {
1805 let request = InitializeRequest::new(ProtocolVersion::V1);
1806 let response = initialize(&state(), &request);
1807
1808 assert_eq!(response.protocol_version, ProtocolVersion::V1);
1809 assert_eq!(
1810 response.agent_info,
1811 Some(Implementation::new("thndrs", env!("CARGO_PKG_VERSION")).title(Some("thndrs".to_string())))
1812 );
1813 let expected = AgentCapabilities::new()
1814 .load_session(true)
1815 .prompt_capabilities(PromptCapabilities::new().image(true).embedded_context(true))
1816 .mcp_capabilities(McpCapabilities::new())
1817 .session_capabilities(
1818 SessionCapabilities::new()
1819 .list(SessionListCapabilities::new())
1820 .resume(SessionResumeCapabilities::new())
1821 .close(SessionCloseCapabilities::new())
1822 .delete(SessionDeleteCapabilities::new()),
1823 );
1824 assert_eq!(response.agent_capabilities, expected);
1825 }
1826
1827 #[test]
1828 fn initializes_with_protocol_fallback_to_supported_version() {
1829 let request = InitializeRequest::new(ProtocolVersion::from(2u16));
1830 let response = initialize(&state(), &request);
1831
1832 assert_eq!(response.protocol_version, ProtocolVersion::V1);
1833 assert!(
1834 response.agent_capabilities.prompt_capabilities
1835 == PromptCapabilities::new().image(true).embedded_context(true)
1836 );
1837 }
1838
1839 #[test]
1840 fn records_client_filesystem_capabilities() {
1841 let state = state();
1842 let request = InitializeRequest::new(ProtocolVersion::V1).client_capabilities(
1843 ClientCapabilities::new().fs(FileSystemCapabilities::new().read_text_file(true).write_text_file(true)),
1844 );
1845
1846 let _response = initialize(&state, &request);
1847
1848 assert!(state.client_can_read_text_files());
1849 assert!(state.client_can_write_text_files());
1850 }
1851
1852 #[test]
1853 fn list_sessions_reads_persisted_jsonl() {
1854 let workspace = tempfile::tempdir().expect("temp workspace");
1855 let workspace_path = workspace.path().canonicalize().expect("canonical workspace");
1856 let session_dir = tempfile::tempdir().expect("temp sessions");
1857 let mut writer = SessionWriter::create(
1858 session_dir.path(),
1859 "persisted-1",
1860 &workspace_path.display().to_string(),
1861 "Saved Session",
1862 "thndrs",
1863 "umans-coder",
1864 "auto",
1865 env!("CARGO_PKG_VERSION"),
1866 None,
1867 )
1868 .expect("create persisted session");
1869 writer
1870 .append(SessionRecord::AssistantFinished {
1871 schema_version: SCHEMA_VERSION,
1872 seq: 0,
1873 time: crate::utils::datetime::now_iso8601(),
1874 turn_id: "turn-1".to_string(),
1875 text: "saved answer".to_string(),
1876 })
1877 .expect("append assistant");
1878 let state = ServerState::new(ServerConfig::new(
1879 workspace_path.clone(),
1880 "umans-coder".to_string(),
1881 "duckduckgo".to_string(),
1882 Some(session_dir.path().to_path_buf()),
1883 ));
1884
1885 let response =
1886 list_sessions(&state, &ListSessionsRequest::new().cwd(Some(workspace_path))).expect("list sessions");
1887
1888 assert_eq!(response.sessions.len(), 1);
1889 assert_eq!(response.sessions[0].session_id.0.as_ref(), "persisted-1");
1890 assert_eq!(response.sessions[0].title.as_deref(), Some("Saved Session"));
1891 }
1892
1893 #[test]
1894 fn load_session_replays_records_in_file_order() {
1895 let records = [
1896 SessionRecord::User {
1897 schema_version: SCHEMA_VERSION,
1898 seq: 1,
1899 time: "2026-07-05T00:00:00Z".to_string(),
1900 turn_id: "turn-1".to_string(),
1901 text: "question".to_string(),
1902 },
1903 SessionRecord::ReasoningFinished {
1904 schema_version: SCHEMA_VERSION,
1905 seq: 2,
1906 time: "2026-07-05T00:00:01Z".to_string(),
1907 turn_id: "turn-1".to_string(),
1908 text: "thought".to_string(),
1909 },
1910 SessionRecord::AssistantFinished {
1911 schema_version: SCHEMA_VERSION,
1912 seq: 3,
1913 time: "2026-07-05T00:00:02Z".to_string(),
1914 turn_id: "turn-1".to_string(),
1915 text: "answer".to_string(),
1916 },
1917 ];
1918
1919 let kinds = records
1920 .iter()
1921 .filter_map(replay_record_update)
1922 .map(|intent| match intent {
1923 SessionUpdateIntent::Status(_) => "status",
1924 SessionUpdateIntent::ReasoningDelta(_) => "reasoning",
1925 SessionUpdateIntent::AssistantDelta(_) => "assistant",
1926 _ => "other",
1927 })
1928 .collect::<Vec<_>>();
1929
1930 assert_eq!(kinds, vec!["status", "reasoning", "assistant"]);
1931 }
1932
1933 #[test]
1934 fn resume_session_attaches_without_replay_requirement() {
1935 let workspace = tempfile::tempdir().expect("temp workspace");
1936 let workspace_path = workspace.path().canonicalize().expect("canonical workspace");
1937 let session_dir = tempfile::tempdir().expect("temp sessions");
1938 let writer = SessionWriter::create(
1939 session_dir.path(),
1940 "persisted-resume",
1941 &workspace_path.display().to_string(),
1942 "Resume Session",
1943 "thndrs",
1944 "umans-coder",
1945 "auto",
1946 env!("CARGO_PKG_VERSION"),
1947 None,
1948 )
1949 .expect("create persisted session");
1950 drop(writer);
1951 let state = ServerState::new(ServerConfig::new(
1952 workspace_path.clone(),
1953 "umans-coder".to_string(),
1954 "duckduckgo".to_string(),
1955 Some(session_dir.path().to_path_buf()),
1956 ));
1957
1958 let response = resume_session(&state, &ResumeSessionRequest::new("persisted-resume", workspace_path))
1959 .expect("resume session");
1960
1961 assert!(response.config_options.is_some());
1962 assert!(state.has_session("persisted-resume"));
1963 }
1964
1965 #[test]
1966 fn rejects_unsupported_prompt_blocks() {
1967 let err = assemble_prompt(&[ContentBlock::Audio(AudioContent::new("aGVsbG8=", "audio/wav"))])
1968 .expect_err("unsupported block rejected");
1969
1970 assert!(err.contains("unsupported ACP prompt content block"));
1971 }
1972}