Skip to main content

thndrs_lib/server/
handlers.rs

1//! ACP server request and notification handlers.
2
3use 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/// Shared handler state for one ACP stdio server process.
42#[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    /// Create process-local ACP server state.
60    pub fn new(config: ServerConfig) -> Self {
61        Self { config, inner: Arc::new(Mutex::new(ServerStateInner::default())) }
62    }
63
64    /// Register a new ACP session and return its opaque session id.
65    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    /// Return whether the initialized ACP client can run terminal commands.
156    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    /// Return whether the initialized ACP client can serve editor-visible reads.
164    pub fn client_can_read_text_files(&self) -> bool {
165        self.client_fs_capabilities().read_text_file
166    }
167
168    /// Return whether the initialized ACP client can perform editor-visible writes.
169    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    /// Mark a known session cancelled.
425    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    /// Associate a running turn's cancel token with the session.
447    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    /// Drop any active-turn cancellation token for a finished turn.
454    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    /// Return whether a session exists.
462    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
661/// Run the ACP server over stdio with all v1 baseline handlers registered.
662pub 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
892/// Read editor-visible text from an ACP client when it advertised support.
893pub 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
909/// Write editor-visible text through an ACP client when it advertised support.
910pub 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
1323/// Run one harness-backed ACP prompt turn and stream updates while pending.
1324fn 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}