Skip to main content

reddb_server/acp/
server.rs

1//! Negotiated ACP v1/v2 session adapter over the RedDB ASK harness.
2
3use std::collections::HashMap;
4use std::io::{self, BufRead};
5use std::path::Path;
6use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
7use std::sync::{mpsc, Arc};
8use std::thread;
9
10use parking_lot::{Condvar, Mutex};
11
12use super::protocol;
13use crate::json::{from_str as json_from_str, Map, Value as JsonValue};
14use crate::runtime::ask_pipeline::AskScopeGuard;
15use crate::runtime::execution_context::{self, SnapshotBundle};
16use crate::runtime::query_budget::RequestCancellation;
17use crate::runtime::RedDBRuntime;
18
19const ACP_PROTOCOL_V1: i64 = 1;
20const ACP_PROTOCOL_V2: i64 = 2;
21const OUTPUT_QUEUE_CAPACITY: usize = 256;
22const MAX_SESSIONS: usize = 64;
23const MAX_MESSAGE_BYTES: usize = 1024 * 1024;
24const MAX_PROMPT_BYTES: usize = 128 * 1024;
25
26pub struct AcpServer {
27    runtime: RedDBRuntime,
28    state: Arc<Mutex<ServerState>>,
29    next_session: AtomicU64,
30}
31
32struct ServerState {
33    protocol_version: Option<ProtocolVersion>,
34    sessions: HashMap<String, Arc<AcpSession>>,
35}
36
37struct AcpSession {
38    identity: SnapshotBundle,
39    cwd: String,
40    active: Mutex<Option<ActiveTurn>>,
41    active_finished: Condvar,
42    next_message: AtomicU64,
43    next_tool_call: AtomicU64,
44}
45
46#[derive(Clone, Copy, Debug, Eq, PartialEq)]
47enum ProtocolVersion {
48    V1,
49    V2,
50}
51
52#[derive(Clone)]
53struct ActiveTurn {
54    cancellation: RequestCancellation,
55    cancelled: Arc<AtomicBool>,
56}
57
58struct ActivePromptGuard<'a> {
59    session: &'a AcpSession,
60}
61
62impl Drop for ActivePromptGuard<'_> {
63    fn drop(&mut self) {
64        *self.session.active.lock() = None;
65        self.session.active_finished.notify_all();
66    }
67}
68
69struct PromptJob {
70    id: JsonValue,
71    session_id: String,
72    question: String,
73    prompt: JsonValue,
74    protocol_version: ProtocolVersion,
75    user_message_id: Option<String>,
76    session: Arc<AcpSession>,
77}
78
79enum Dispatch {
80    Message(String),
81    Messages(Vec<String>),
82    Prompt {
83        job: PromptJob,
84        accepted: Option<String>,
85    },
86    None,
87}
88
89enum BoundedLine {
90    Eof,
91    Message,
92    TooLarge,
93}
94
95impl AcpServer {
96    pub fn new(runtime: RedDBRuntime) -> Self {
97        Self {
98            runtime,
99            state: Arc::new(Mutex::new(ServerState {
100                protocol_version: None,
101                sessions: HashMap::new(),
102            })),
103            next_session: AtomicU64::new(1),
104        }
105    }
106
107    /// Serve negotiated ACP v1/v2 over newline-delimited JSON-RPC on stdio.
108    /// Prompt turns run on dedicated workers so the reader can process
109    /// `session/cancel` while ASK is executing.
110    pub fn run_stdio(&self) {
111        let (output_tx, output_rx) = mpsc::sync_channel::<String>(OUTPUT_QUEUE_CAPACITY);
112        thread::spawn(move || {
113            let stdout = io::stdout();
114            let mut writer = io::BufWriter::new(stdout.lock());
115            for message in output_rx {
116                if protocol::write_message(&mut writer, &message).is_err() {
117                    break;
118                }
119            }
120        });
121
122        let stdin = io::stdin();
123        let mut reader = io::BufReader::new(stdin.lock());
124        let mut line = Vec::new();
125        loop {
126            match read_bounded_line(&mut reader, &mut line) {
127                Ok(BoundedLine::Eof) => break,
128                Ok(BoundedLine::Message) => {}
129                Ok(BoundedLine::TooLarge) => {
130                    let _ = output_tx.send(protocol::build_error_message(
131                        None,
132                        -32600,
133                        "message exceeds the 1 MiB limit",
134                    ));
135                    continue;
136                }
137                Err(error) => {
138                    tracing::error!(target: "reddb::acp", err = %error, "ACP stdin read failed");
139                    break;
140                }
141            }
142            let Ok(payload) = std::str::from_utf8(&line) else {
143                let _ = output_tx.send(protocol::build_error_message(None, -32700, "parse error"));
144                continue;
145            };
146            let payload = payload.trim_end_matches(['\r', '\n']);
147            if payload.is_empty() {
148                continue;
149            }
150            let message = match json_from_str(payload) {
151                Ok(message) => message,
152                Err(_) => {
153                    let _ =
154                        output_tx.send(protocol::build_error_message(None, -32700, "parse error"));
155                    continue;
156                }
157            };
158
159            match self.dispatch(&message) {
160                Dispatch::Message(message) => {
161                    let _ = output_tx.send(message);
162                }
163                Dispatch::Messages(messages) => {
164                    for message in messages {
165                        let _ = output_tx.send(message);
166                    }
167                }
168                Dispatch::Prompt { job, accepted } => {
169                    if let Some(accepted) = accepted {
170                        let _ = output_tx.send(accepted);
171                    }
172                    let runtime = self.runtime.clone();
173                    let worker_tx = output_tx.clone();
174                    thread::spawn(move || run_prompt(runtime, job, worker_tx));
175                }
176                Dispatch::None => {}
177            }
178        }
179
180        self.cancel_all();
181    }
182
183    fn dispatch(&self, message: &JsonValue) -> Dispatch {
184        if message.get("jsonrpc").and_then(JsonValue::as_str) != Some("2.0") {
185            return Dispatch::Message(protocol::build_error_message(
186                message.get("id"),
187                -32600,
188                "invalid JSON-RPC version",
189            ));
190        }
191        let Some(method) = message.get("method").and_then(JsonValue::as_str) else {
192            return Dispatch::Message(protocol::build_error_message(
193                message.get("id"),
194                -32600,
195                "invalid request",
196            ));
197        };
198        let id = message.get("id");
199        if id.is_some_and(|id| {
200            !matches!(
201                id,
202                JsonValue::Null | JsonValue::Integer(_) | JsonValue::String(_)
203            )
204        }) {
205            return Dispatch::Message(protocol::build_error_message(
206                None,
207                -32600,
208                "request id must be a string or integer",
209            ));
210        }
211        let params = message.get("params");
212
213        if method == "initialize" {
214            return Dispatch::Message(self.initialize(id, params));
215        }
216        let Some(protocol_version) = self.state.lock().protocol_version else {
217            return Dispatch::Message(protocol::build_error_message(
218                id,
219                -32600,
220                "initialize must be called first",
221            ));
222        };
223        if protocol_version == ProtocolVersion::V2 && matches!(id, Some(JsonValue::Null)) {
224            return Dispatch::Message(protocol::build_error_message(
225                None,
226                -32600,
227                "ACP v2 request id must not be null",
228            ));
229        }
230
231        match method {
232            "session/new" => Dispatch::Message(self.new_session(protocol_version, id, params)),
233            "session/list" if protocol_version == ProtocolVersion::V2 => {
234                self.list_sessions(id, params)
235            }
236            "session/resume" if protocol_version == ProtocolVersion::V2 => {
237                self.resume_session(id, params)
238            }
239            "session/prompt" => self.prompt(protocol_version, id, params),
240            "session/cancel" => {
241                self.cancel(params);
242                Dispatch::None
243            }
244            "session/close" => Dispatch::Message(self.close_session(protocol_version, id, params)),
245            _ => Dispatch::Message(protocol::build_error_message(
246                id,
247                -32601,
248                &format!("method not found: {method}"),
249            )),
250        }
251    }
252
253    fn initialize(&self, id: Option<&JsonValue>, params: Option<&JsonValue>) -> String {
254        if id.is_none() {
255            return protocol::build_error_message(None, -32600, "initialize requires an id");
256        }
257        let Some(requested_version) = params
258            .and_then(|value| value.get("protocolVersion"))
259            .and_then(JsonValue::as_i64)
260        else {
261            return protocol::build_error_message(id, -32602, "protocolVersion is required");
262        };
263        if requested_version <= 0 {
264            return protocol::build_error_message(id, -32022, "unsupported protocol version");
265        }
266        if requested_version > u16::MAX as i64 {
267            return protocol::build_error_message(id, -32602, "protocolVersion exceeds uint16");
268        }
269        if self.state.lock().protocol_version.is_some() {
270            return protocol::build_error_message(id, -32600, "connection is already initialized");
271        }
272
273        let protocol_version = if requested_version >= ACP_PROTOCOL_V2 {
274            ProtocolVersion::V2
275        } else {
276            ProtocolVersion::V1
277        };
278        if protocol_version == ProtocolVersion::V2 && matches!(id, Some(JsonValue::Null)) {
279            return protocol::build_error_message(
280                None,
281                -32600,
282                "ACP v2 request id must not be null",
283            );
284        }
285        if protocol_version == ProtocolVersion::V2 {
286            let valid_info = params
287                .and_then(|value| value.get("info"))
288                .is_some_and(|info| {
289                    info.get("name").and_then(JsonValue::as_str).is_some()
290                        && info.get("version").and_then(JsonValue::as_str).is_some()
291                });
292            if !valid_info {
293                return protocol::build_error_message(
294                    id,
295                    -32602,
296                    "ACP v2 initialize requires info.name and info.version",
297                );
298            }
299        }
300        self.state.lock().protocol_version = Some(protocol_version);
301
302        match protocol_version {
303            ProtocolVersion::V1 => initialize_v1(id),
304            ProtocolVersion::V2 => initialize_v2(id),
305        }
306    }
307
308    fn new_session(
309        &self,
310        protocol_version: ProtocolVersion,
311        id: Option<&JsonValue>,
312        params: Option<&JsonValue>,
313    ) -> String {
314        if id.is_none() {
315            return protocol::build_error_message(None, -32600, "session/new requires an id");
316        }
317        let Some(params) = params else {
318            return protocol::build_error_message(id, -32602, "params are required");
319        };
320        let Some(cwd) = params.get("cwd").and_then(JsonValue::as_str) else {
321            return protocol::build_error_message(id, -32602, "cwd is required");
322        };
323        if !Path::new(cwd).is_absolute() {
324            return protocol::build_error_message(id, -32602, "cwd must be absolute");
325        }
326        match params.get("mcpServers") {
327            Some(JsonValue::Array(servers)) if servers.is_empty() => {}
328            Some(JsonValue::Array(_)) => {
329                return protocol::build_error_message(
330                    id,
331                    -32602,
332                    "external MCP servers are not supported by this ACP adapter",
333                );
334            }
335            None | Some(JsonValue::Null) if protocol_version == ProtocolVersion::V2 => {}
336            _ => {
337                return protocol::build_error_message(id, -32602, "mcpServers must be an array");
338            }
339        }
340        if has_unadvertised_session_options(params) {
341            return protocol::build_error_message(
342                id,
343                -32602,
344                "unadvertised session capabilities are not supported",
345            );
346        }
347
348        let mut state = self.state.lock();
349        if state.sessions.len() >= MAX_SESSIONS {
350            return protocol::build_error_message(id, -32603, "session limit reached");
351        }
352        let sequence = self.next_session.fetch_add(1, Ordering::Relaxed);
353        let session_id = format!("reddb-{}-{sequence}", std::process::id());
354        let mut identity = execution_context::snapshot_bundle();
355        // Authorization belongs to the ACP session, but MVCC visibility does
356        // not. Every prompt must capture a fresh statement snapshot so writes
357        // committed after session/new remain visible.
358        identity.snapshot = None;
359        state.sessions.insert(
360            session_id.clone(),
361            Arc::new(AcpSession {
362                identity,
363                cwd: cwd.to_string(),
364                active: Mutex::new(None),
365                active_finished: Condvar::new(),
366                next_message: AtomicU64::new(1),
367                next_tool_call: AtomicU64::new(1),
368            }),
369        );
370
371        let mut result = Map::new();
372        result.insert("sessionId".into(), JsonValue::String(session_id));
373        protocol::build_result_message(id, JsonValue::Object(result))
374    }
375
376    fn list_sessions(&self, id: Option<&JsonValue>, params: Option<&JsonValue>) -> Dispatch {
377        if id.is_none() {
378            return Dispatch::Message(protocol::build_error_message(
379                None,
380                -32600,
381                "session/list requires an id",
382            ));
383        }
384        let cwd = match params.and_then(|value| value.get("cwd")) {
385            None | Some(JsonValue::Null) => None,
386            Some(JsonValue::String(cwd)) => Some(cwd.as_str()),
387            Some(_) => {
388                return Dispatch::Message(protocol::build_error_message(
389                    id,
390                    -32602,
391                    "cwd must be a string",
392                ));
393            }
394        };
395        if cwd.is_some_and(|path| !Path::new(path).is_absolute()) {
396            return Dispatch::Message(protocol::build_error_message(
397                id,
398                -32602,
399                "cwd must be absolute",
400            ));
401        }
402        if params
403            .and_then(|value| value.get("cursor"))
404            .is_some_and(|cursor| !matches!(cursor, JsonValue::Null))
405        {
406            return Dispatch::Message(protocol::build_error_message(
407                id,
408                -32602,
409                "session/list cursor is not valid for an unpaginated result",
410            ));
411        }
412
413        let state = self.state.lock();
414        let mut sessions = state
415            .sessions
416            .iter()
417            .filter(|(_, session)| cwd.is_none_or(|cwd| session.cwd == cwd))
418            .map(|(session_id, session)| session_info(session_id, session))
419            .collect::<Vec<_>>();
420        sessions.sort_by(|left, right| {
421            left.get("sessionId")
422                .and_then(JsonValue::as_str)
423                .cmp(&right.get("sessionId").and_then(JsonValue::as_str))
424        });
425
426        let mut result = Map::new();
427        result.insert("sessions".into(), JsonValue::Array(sessions));
428        Dispatch::Message(protocol::build_result_message(
429            id,
430            JsonValue::Object(result),
431        ))
432    }
433
434    fn resume_session(&self, id: Option<&JsonValue>, params: Option<&JsonValue>) -> Dispatch {
435        if id.is_none() {
436            return Dispatch::Message(protocol::build_error_message(
437                None,
438                -32600,
439                "session/resume requires an id",
440            ));
441        }
442        let Some(params) = params else {
443            return Dispatch::Message(protocol::build_error_message(
444                id,
445                -32602,
446                "params are required",
447            ));
448        };
449        let Some(session_id) = params.get("sessionId").and_then(JsonValue::as_str) else {
450            return Dispatch::Message(protocol::build_error_message(
451                id,
452                -32602,
453                "sessionId is required",
454            ));
455        };
456        let Some(cwd) = params.get("cwd").and_then(JsonValue::as_str) else {
457            return Dispatch::Message(protocol::build_error_message(id, -32602, "cwd is required"));
458        };
459        let Some(session) = self.state.lock().sessions.get(session_id).cloned() else {
460            return Dispatch::Message(protocol::build_error_message(
461                id,
462                -32002,
463                "session not found",
464            ));
465        };
466        if !Path::new(cwd).is_absolute() || cwd != session.cwd {
467            return Dispatch::Message(protocol::build_error_message(
468                id,
469                -32602,
470                "cwd does not match the session",
471            ));
472        }
473        if has_unadvertised_session_options(params)
474            || params.get("mcpServers").is_some_and(|value| match value {
475                JsonValue::Null => false,
476                JsonValue::Array(servers) => !servers.is_empty(),
477                _ => true,
478            })
479            || params
480                .get("replayFrom")
481                .is_some_and(|cursor| !matches!(cursor, JsonValue::Null))
482        {
483            return Dispatch::Message(protocol::build_error_message(
484                id,
485                -32602,
486                "unsupported session/resume option",
487            ));
488        }
489
490        let state_update = if session.active.lock().is_some() {
491            v2_state_update(session_id, "running", None, None)
492        } else {
493            v2_state_update(session_id, "idle", None, None)
494        };
495        let response = protocol::build_result_message(id, JsonValue::Object(Map::new()));
496        Dispatch::Messages(vec![state_update, response])
497    }
498
499    fn prompt(
500        &self,
501        protocol_version: ProtocolVersion,
502        id: Option<&JsonValue>,
503        params: Option<&JsonValue>,
504    ) -> Dispatch {
505        let Some(id) = id.cloned() else {
506            return Dispatch::Message(protocol::build_error_message(
507                None,
508                -32600,
509                "session/prompt requires an id",
510            ));
511        };
512        let Some(params) = params else {
513            return Dispatch::Message(protocol::build_error_message(
514                Some(&id),
515                -32602,
516                "params are required",
517            ));
518        };
519        let Some(session_id) = params.get("sessionId").and_then(JsonValue::as_str) else {
520            return Dispatch::Message(protocol::build_error_message(
521                Some(&id),
522                -32602,
523                "sessionId is required",
524            ));
525        };
526        let session = {
527            let state = self.state.lock();
528            match state.sessions.get(session_id) {
529                Some(session) => Arc::clone(session),
530                None => {
531                    return Dispatch::Message(protocol::build_error_message(
532                        Some(&id),
533                        -32002,
534                        "session not found",
535                    ));
536                }
537            }
538        };
539        let question = match prompt_text(params) {
540            Ok(question) => question,
541            Err(message) => {
542                return Dispatch::Message(protocol::build_error_message(
543                    Some(&id),
544                    -32602,
545                    &message,
546                ));
547            }
548        };
549
550        let mut active = session.active.lock();
551        if active.is_some() {
552            return Dispatch::Message(protocol::build_error_message(
553                Some(&id),
554                -32600,
555                "session already has an active prompt",
556            ));
557        }
558        *active = Some(ActiveTurn {
559            cancellation: RequestCancellation::new(),
560            cancelled: Arc::new(AtomicBool::new(false)),
561        });
562        drop(active);
563
564        let prompt = params.get("prompt").cloned().unwrap_or(JsonValue::Null);
565        let user_message_id = (protocol_version == ProtocolVersion::V2).then(|| {
566            let sequence = session.next_message.fetch_add(1, Ordering::Relaxed);
567            format!("message-{sequence}")
568        });
569        let accepted = user_message_id.as_ref().map(|message_id| {
570            let mut result = Map::new();
571            result.insert("messageId".into(), JsonValue::String(message_id.clone()));
572            protocol::build_result_message(Some(&id), JsonValue::Object(result))
573        });
574
575        Dispatch::Prompt {
576            job: PromptJob {
577                id,
578                session_id: session_id.to_string(),
579                question,
580                prompt,
581                protocol_version,
582                user_message_id,
583                session,
584            },
585            accepted,
586        }
587    }
588
589    fn cancel(&self, params: Option<&JsonValue>) {
590        let Some(session_id) = params
591            .and_then(|value| value.get("sessionId"))
592            .and_then(JsonValue::as_str)
593        else {
594            return;
595        };
596        let session = self.state.lock().sessions.get(session_id).cloned();
597        if let Some(session) = session {
598            if let Some(active) = session.active.lock().as_ref() {
599                active.cancelled.store(true, Ordering::Release);
600                active.cancellation.cancel();
601            }
602        }
603    }
604
605    fn close_session(
606        &self,
607        protocol_version: ProtocolVersion,
608        id: Option<&JsonValue>,
609        params: Option<&JsonValue>,
610    ) -> String {
611        if id.is_none() {
612            return protocol::build_error_message(None, -32600, "session/close requires an id");
613        }
614        let Some(session_id) = params
615            .and_then(|value| value.get("sessionId"))
616            .and_then(JsonValue::as_str)
617        else {
618            return protocol::build_error_message(id, -32602, "sessionId is required");
619        };
620        let Some(session) = self.state.lock().sessions.remove(session_id) else {
621            return if protocol_version == ProtocolVersion::V2 {
622                protocol::build_result_message(id, JsonValue::Object(Map::new()))
623            } else {
624                protocol::build_error_message(id, -32002, "session not found")
625            };
626        };
627        let mut active = session.active.lock();
628        if let Some(turn) = active.as_ref() {
629            turn.cancelled.store(true, Ordering::Release);
630            turn.cancellation.cancel();
631        }
632        while active.is_some() {
633            session.active_finished.wait(&mut active);
634        }
635        protocol::build_result_message(id, JsonValue::Object(Map::new()))
636    }
637
638    fn cancel_all(&self) {
639        for session in self.state.lock().sessions.values() {
640            if let Some(active) = session.active.lock().as_ref() {
641                active.cancelled.store(true, Ordering::Release);
642                active.cancellation.cancel();
643            }
644        }
645    }
646}
647
648fn initialize_v1(id: Option<&JsonValue>) -> String {
649    let mut prompt_capabilities = Map::new();
650    prompt_capabilities.insert("audio".into(), JsonValue::Bool(false));
651    prompt_capabilities.insert("embeddedContext".into(), JsonValue::Bool(false));
652    prompt_capabilities.insert("image".into(), JsonValue::Bool(false));
653
654    let mut mcp_capabilities = Map::new();
655    mcp_capabilities.insert("http".into(), JsonValue::Bool(false));
656    mcp_capabilities.insert("sse".into(), JsonValue::Bool(false));
657
658    let mut session_capabilities = Map::new();
659    session_capabilities.insert("close".into(), JsonValue::Object(Map::new()));
660
661    let mut capabilities = Map::new();
662    capabilities.insert("auth".into(), JsonValue::Object(Map::new()));
663    capabilities.insert("loadSession".into(), JsonValue::Bool(false));
664    capabilities.insert(
665        "mcpCapabilities".into(),
666        JsonValue::Object(mcp_capabilities),
667    );
668    capabilities.insert(
669        "promptCapabilities".into(),
670        JsonValue::Object(prompt_capabilities),
671    );
672    capabilities.insert(
673        "sessionCapabilities".into(),
674        JsonValue::Object(session_capabilities),
675    );
676
677    let mut result = Map::new();
678    result.insert("agentCapabilities".into(), JsonValue::Object(capabilities));
679    result.insert("agentInfo".into(), agent_info());
680    result.insert("authMethods".into(), JsonValue::Array(Vec::new()));
681    result.insert(
682        "protocolVersion".into(),
683        JsonValue::Integer(ACP_PROTOCOL_V1),
684    );
685    protocol::build_result_message(id, JsonValue::Object(result))
686}
687
688fn initialize_v2(id: Option<&JsonValue>) -> String {
689    let mut capabilities = Map::new();
690    capabilities.insert("session".into(), JsonValue::Object(Map::new()));
691
692    let mut result = Map::new();
693    result.insert("authMethods".into(), JsonValue::Array(Vec::new()));
694    result.insert("capabilities".into(), JsonValue::Object(capabilities));
695    result.insert("info".into(), agent_info());
696    result.insert(
697        "protocolVersion".into(),
698        JsonValue::Integer(ACP_PROTOCOL_V2),
699    );
700    protocol::build_result_message(id, JsonValue::Object(result))
701}
702
703fn agent_info() -> JsonValue {
704    let mut info = Map::new();
705    info.insert("name".into(), JsonValue::String("reddb".into()));
706    info.insert("title".into(), JsonValue::String("RedDB ASK".into()));
707    info.insert(
708        "version".into(),
709        JsonValue::String(env!("CARGO_PKG_VERSION").into()),
710    );
711    JsonValue::Object(info)
712}
713
714fn has_unadvertised_session_options(params: &JsonValue) -> bool {
715    params
716        .get("additionalDirectories")
717        .is_some_and(|value| match value {
718            JsonValue::Null => false,
719            JsonValue::Array(directories) => !directories.is_empty(),
720            _ => true,
721        })
722        || params
723            .get("systemPrompt")
724            .is_some_and(|prompt| !matches!(prompt, JsonValue::Null))
725}
726
727fn session_info(session_id: &str, session: &AcpSession) -> JsonValue {
728    let mut info = Map::new();
729    info.insert("cwd".into(), JsonValue::String(session.cwd.to_string()));
730    info.insert(
731        "sessionId".into(),
732        JsonValue::String(session_id.to_string()),
733    );
734    JsonValue::Object(info)
735}
736
737fn read_bounded_line<R: BufRead>(reader: &mut R, line: &mut Vec<u8>) -> io::Result<BoundedLine> {
738    line.clear();
739    let mut too_large = false;
740    loop {
741        let available = reader.fill_buf()?;
742        if available.is_empty() {
743            return Ok(if too_large {
744                BoundedLine::TooLarge
745            } else if line.is_empty() {
746                BoundedLine::Eof
747            } else {
748                BoundedLine::Message
749            });
750        }
751
752        let newline = available.iter().position(|byte| *byte == b'\n');
753        let consumed = newline.map_or(available.len(), |index| index + 1);
754        let payload_bytes = newline.map_or(consumed, |index| index);
755        if !too_large {
756            if line.len().saturating_add(payload_bytes) > MAX_MESSAGE_BYTES {
757                too_large = true;
758            } else {
759                line.extend_from_slice(&available[..consumed]);
760            }
761        }
762        reader.consume(consumed);
763
764        if newline.is_some() {
765            return Ok(if too_large {
766                BoundedLine::TooLarge
767            } else {
768                BoundedLine::Message
769            });
770        }
771    }
772}
773
774fn prompt_text(params: &JsonValue) -> Result<String, String> {
775    let blocks = params
776        .get("prompt")
777        .and_then(JsonValue::as_array)
778        .ok_or_else(|| "prompt is required".to_string())?;
779    let mut parts = Vec::with_capacity(blocks.len());
780    let mut bytes = 0_usize;
781    for block in blocks {
782        let block_type = block
783            .get("type")
784            .and_then(JsonValue::as_str)
785            .ok_or_else(|| "prompt block type is required".to_string())?;
786        let part = match block_type {
787            "text" => block
788                .get("text")
789                .and_then(JsonValue::as_str)
790                .ok_or_else(|| "text prompt block requires text".to_string())?
791                .to_string(),
792            "resource_link" => {
793                let name = block
794                    .get("name")
795                    .and_then(JsonValue::as_str)
796                    .ok_or_else(|| "resource_link requires name".to_string())?;
797                let uri = block
798                    .get("uri")
799                    .and_then(JsonValue::as_str)
800                    .ok_or_else(|| "resource_link requires uri".to_string())?;
801                format!("Referenced resource: {name} ({uri})")
802            }
803            unsupported => {
804                return Err(format!("unsupported prompt block type: {unsupported}"));
805            }
806        };
807        if !parts.is_empty() {
808            bytes = bytes.saturating_add(2);
809        }
810        bytes = bytes.saturating_add(part.len());
811        if bytes > MAX_PROMPT_BYTES {
812            return Err("prompt exceeds the 128 KiB limit".to_string());
813        }
814        parts.push(part);
815    }
816    let question = parts.join("\n\n");
817    if question.trim().is_empty() {
818        return Err("prompt must contain text or a resource link".to_string());
819    }
820    Ok(question)
821}
822
823fn run_prompt(runtime: RedDBRuntime, job: PromptJob, output: mpsc::SyncSender<String>) {
824    let active = job.session.active.lock().clone();
825    let Some(active) = active else {
826        return;
827    };
828    let _active_guard = ActivePromptGuard {
829        session: &job.session,
830    };
831    if job.protocol_version == ProtocolVersion::V2 {
832        let _ = output.send(v2_state_update(&job.session_id, "running", None, None));
833        if let Some(message_id) = job.user_message_id.as_deref() {
834            let _ = output.send(v2_message_update(
835                &job.session_id,
836                "user_message",
837                message_id,
838                job.prompt.clone(),
839            ));
840        }
841    }
842    let result = execution_context::with_snapshot_bundle(&job.session.identity, || {
843        let _cancellation = active.cancellation.install();
844        let scope = runtime.ai_scope();
845        let _scope = AskScopeGuard::install(&runtime, &scope);
846        runtime.execute_ask("ASK <acp>", &ask_query(job.question))
847    });
848
849    let cancelled = active.cancelled.load(Ordering::Acquire);
850    if cancelled {
851        let message = match job.protocol_version {
852            ProtocolVersion::V1 => prompt_response(&job.id, "cancelled"),
853            ProtocolVersion::V2 => {
854                v2_state_update(&job.session_id, "idle", Some("cancelled"), None)
855            }
856        };
857        let _ = output.send(message);
858    } else {
859        match result {
860            Ok(result) => {
861                let envelope = crate::presentation::query_result::tagged_summary(&result);
862                let messages = match job.protocol_version {
863                    ProtocolVersion::V1 => {
864                        turn_messages_v1(&job.session_id, &envelope, &job.session.next_tool_call)
865                    }
866                    ProtocolVersion::V2 => {
867                        turn_messages_v2(&job.session_id, &envelope, &job.session)
868                    }
869                };
870                for message in messages {
871                    let _ = output.send(message);
872                }
873                let completion = match job.protocol_version {
874                    ProtocolVersion::V1 => prompt_response(&job.id, "end_turn"),
875                    ProtocolVersion::V2 => {
876                        v2_state_update(&job.session_id, "idle", Some("end_turn"), None)
877                    }
878                };
879                let _ = output.send(completion);
880            }
881            Err(error) => {
882                let message = match job.protocol_version {
883                    ProtocolVersion::V1 => {
884                        protocol::build_error_message(Some(&job.id), -32603, &error.to_string())
885                    }
886                    ProtocolVersion::V2 => v2_state_update(
887                        &job.session_id,
888                        "idle",
889                        Some("error"),
890                        Some(&error.to_string()),
891                    ),
892                };
893                let _ = output.send(message);
894            }
895        }
896    }
897}
898
899fn ask_query(question: String) -> reddb_rql::ast::AskQuery {
900    reddb_rql::ast::AskQuery {
901        explain: false,
902        question,
903        question_param: None,
904        provider: None,
905        model: None,
906        depth: None,
907        limit: None,
908        min_score: None,
909        collection: None,
910        temperature: None,
911        seed: None,
912        strict: true,
913        stream: false,
914        cache: reddb_rql::ast::AskCacheClause::Default,
915        plan_only: false,
916        steps: None,
917    }
918}
919
920fn turn_messages_v1(
921    session_id: &str,
922    envelope: &JsonValue,
923    next_tool_call: &AtomicU64,
924) -> Vec<String> {
925    let mut messages = Vec::new();
926    if let Some(summary) = envelope
927        .get("reasoning")
928        .and_then(|reasoning| reasoning.get("summary"))
929        .and_then(JsonValue::as_str)
930        .filter(|summary| !summary.is_empty())
931    {
932        messages.push(v1_text_update(session_id, "agent_thought_chunk", summary));
933    }
934
935    if let Some(steps) = envelope
936        .get("reasoning")
937        .and_then(|reasoning| reasoning.get("steps"))
938        .and_then(JsonValue::as_array)
939    {
940        for step in steps {
941            let tool_call_id = next_tool_call.fetch_add(1, Ordering::Relaxed);
942            messages.push(v1_tool_update(session_id, tool_call_id, step));
943        }
944    }
945
946    let answer = envelope
947        .get("answer")
948        .and_then(JsonValue::as_str)
949        .unwrap_or_default();
950    messages.push(v1_text_update(session_id, "agent_message_chunk", answer));
951    messages
952}
953
954fn v1_text_update(session_id: &str, kind: &str, text: &str) -> String {
955    let mut content = Map::new();
956    content.insert("text".into(), JsonValue::String(text.to_string()));
957    content.insert("type".into(), JsonValue::String("text".into()));
958
959    let mut update = Map::new();
960    update.insert("content".into(), JsonValue::Object(content));
961    update.insert("sessionUpdate".into(), JsonValue::String(kind.to_string()));
962
963    session_update(session_id, JsonValue::Object(update))
964}
965
966fn v1_tool_update(session_id: &str, sequence: u64, step: &JsonValue) -> String {
967    let name = step
968        .get("tool")
969        .and_then(JsonValue::as_str)
970        .unwrap_or("reddb_query");
971    let title = step
972        .get("purpose")
973        .and_then(JsonValue::as_str)
974        .unwrap_or(name);
975
976    let mut update = Map::new();
977    update.insert("name".into(), JsonValue::String(name.to_string()));
978    update.insert("rawInput".into(), step.clone());
979    if let Some(output) = step.get("observation") {
980        update.insert("rawOutput".into(), output.clone());
981    }
982    update.insert(
983        "sessionUpdate".into(),
984        JsonValue::String("tool_call".into()),
985    );
986    update.insert("status".into(), JsonValue::String("completed".into()));
987    update.insert("title".into(), JsonValue::String(title.to_string()));
988    update.insert(
989        "toolCallId".into(),
990        JsonValue::String(format!("reddb-{sequence}")),
991    );
992    session_update(session_id, JsonValue::Object(update))
993}
994
995fn turn_messages_v2(session_id: &str, envelope: &JsonValue, session: &AcpSession) -> Vec<String> {
996    let mut messages = Vec::new();
997    if let Some(summary) = envelope
998        .get("reasoning")
999        .and_then(|reasoning| reasoning.get("summary"))
1000        .and_then(JsonValue::as_str)
1001        .filter(|summary| !summary.is_empty())
1002    {
1003        let message_id = next_message_id(session);
1004        messages.push(v2_message_update(
1005            session_id,
1006            "agent_thought",
1007            &message_id,
1008            JsonValue::Array(vec![text_content(summary)]),
1009        ));
1010    }
1011
1012    if let Some(steps) = envelope
1013        .get("reasoning")
1014        .and_then(|reasoning| reasoning.get("steps"))
1015        .and_then(JsonValue::as_array)
1016    {
1017        for step in steps {
1018            let sequence = session.next_tool_call.fetch_add(1, Ordering::Relaxed);
1019            messages.push(v2_tool_update(session_id, sequence, step));
1020        }
1021    }
1022
1023    let answer = envelope
1024        .get("answer")
1025        .and_then(JsonValue::as_str)
1026        .unwrap_or_default();
1027    let message_id = next_message_id(session);
1028    messages.push(v2_message_update(
1029        session_id,
1030        "agent_message",
1031        &message_id,
1032        JsonValue::Array(vec![text_content(answer)]),
1033    ));
1034    messages
1035}
1036
1037fn next_message_id(session: &AcpSession) -> String {
1038    let sequence = session.next_message.fetch_add(1, Ordering::Relaxed);
1039    format!("message-{sequence}")
1040}
1041
1042fn text_content(text: &str) -> JsonValue {
1043    let mut content = Map::new();
1044    content.insert("text".into(), JsonValue::String(text.to_string()));
1045    content.insert("type".into(), JsonValue::String("text".into()));
1046    JsonValue::Object(content)
1047}
1048
1049fn v2_message_update(session_id: &str, kind: &str, message_id: &str, content: JsonValue) -> String {
1050    let mut update = Map::new();
1051    update.insert("content".into(), content);
1052    update.insert(
1053        "messageId".into(),
1054        JsonValue::String(message_id.to_string()),
1055    );
1056    update.insert("sessionUpdate".into(), JsonValue::String(kind.to_string()));
1057    session_update(session_id, JsonValue::Object(update))
1058}
1059
1060fn v2_tool_update(session_id: &str, sequence: u64, step: &JsonValue) -> String {
1061    let name = step
1062        .get("tool")
1063        .and_then(JsonValue::as_str)
1064        .unwrap_or("reddb_query");
1065    let title = step
1066        .get("purpose")
1067        .and_then(JsonValue::as_str)
1068        .unwrap_or(name);
1069
1070    let mut update = Map::new();
1071    update.insert("kind".into(), JsonValue::String("search".into()));
1072    update.insert("name".into(), JsonValue::String(name.to_string()));
1073    update.insert("rawInput".into(), step.clone());
1074    if let Some(output) = step.get("observation") {
1075        update.insert("rawOutput".into(), output.clone());
1076    }
1077    update.insert(
1078        "sessionUpdate".into(),
1079        JsonValue::String("tool_call_update".into()),
1080    );
1081    update.insert("status".into(), JsonValue::String("completed".into()));
1082    update.insert("title".into(), JsonValue::String(title.to_string()));
1083    update.insert(
1084        "toolCallId".into(),
1085        JsonValue::String(format!("tool-{sequence}")),
1086    );
1087    session_update(session_id, JsonValue::Object(update))
1088}
1089
1090fn v2_state_update(
1091    session_id: &str,
1092    state: &str,
1093    stop_reason: Option<&str>,
1094    error_message: Option<&str>,
1095) -> String {
1096    let mut update = Map::new();
1097    update.insert(
1098        "sessionUpdate".into(),
1099        JsonValue::String("state_update".into()),
1100    );
1101    update.insert("state".into(), JsonValue::String(state.to_string()));
1102    if let Some(stop_reason) = stop_reason {
1103        update.insert(
1104            "stopReason".into(),
1105            JsonValue::String(stop_reason.to_string()),
1106        );
1107    }
1108    if let Some(message) = error_message {
1109        let mut error = Map::new();
1110        error.insert("code".into(), JsonValue::Integer(-32603));
1111        error.insert("message".into(), JsonValue::String(message.to_string()));
1112        update.insert("error".into(), JsonValue::Object(error));
1113    }
1114    session_update(session_id, JsonValue::Object(update))
1115}
1116
1117fn session_update(session_id: &str, update: JsonValue) -> String {
1118    let mut params = Map::new();
1119    params.insert(
1120        "sessionId".into(),
1121        JsonValue::String(session_id.to_string()),
1122    );
1123    params.insert("update".into(), update);
1124    protocol::build_notification("session/update", JsonValue::Object(params))
1125}
1126
1127fn prompt_response(id: &JsonValue, stop_reason: &str) -> String {
1128    let mut result = Map::new();
1129    result.insert(
1130        "stopReason".into(),
1131        JsonValue::String(stop_reason.to_string()),
1132    );
1133    protocol::build_result_message(Some(id), JsonValue::Object(result))
1134}
1135
1136#[cfg(test)]
1137mod tests {
1138    use super::*;
1139
1140    #[test]
1141    fn oversized_line_is_drained_without_hiding_the_next_message() {
1142        let mut input = vec![b'x'; MAX_MESSAGE_BYTES + 1];
1143        input.extend_from_slice(b"\n{}\n");
1144        let mut reader = io::Cursor::new(input);
1145        let mut line = Vec::new();
1146
1147        assert!(matches!(
1148            read_bounded_line(&mut reader, &mut line).unwrap(),
1149            BoundedLine::TooLarge
1150        ));
1151        assert!(line.len() <= MAX_MESSAGE_BYTES);
1152        assert!(matches!(
1153            read_bounded_line(&mut reader, &mut line).unwrap(),
1154            BoundedLine::Message
1155        ));
1156        assert_eq!(line, b"{}\n");
1157    }
1158
1159    fn object(entries: Vec<(&str, JsonValue)>) -> JsonValue {
1160        JsonValue::Object(
1161            entries
1162                .into_iter()
1163                .map(|(key, value)| (key.to_string(), value))
1164                .collect(),
1165        )
1166    }
1167
1168    fn request(id: i64, method: &str, params: JsonValue) -> JsonValue {
1169        object(vec![
1170            ("id", JsonValue::Integer(id)),
1171            ("jsonrpc", JsonValue::String("2.0".into())),
1172            ("method", JsonValue::String(method.into())),
1173            ("params", params),
1174        ])
1175    }
1176
1177    fn initialized_server() -> AcpServer {
1178        let server = AcpServer::new(RedDBRuntime::in_memory().unwrap());
1179        let response = server.dispatch(&request(
1180            1,
1181            "initialize",
1182            object(vec![("protocolVersion", JsonValue::Integer(1))]),
1183        ));
1184        assert!(matches!(response, Dispatch::Message(_)));
1185        server
1186    }
1187
1188    fn initialized_v2_server() -> AcpServer {
1189        let server = AcpServer::new(RedDBRuntime::in_memory().unwrap());
1190        let response = server.dispatch(&request(
1191            1,
1192            "initialize",
1193            object(vec![
1194                (
1195                    "info",
1196                    object(vec![
1197                        ("name", JsonValue::String("test-client".into())),
1198                        ("version", JsonValue::String("1.0.0".into())),
1199                    ]),
1200                ),
1201                ("protocolVersion", JsonValue::Integer(2)),
1202            ]),
1203        ));
1204        assert!(matches!(response, Dispatch::Message(_)));
1205        server
1206    }
1207
1208    fn new_session(server: &AcpServer) -> (String, Arc<AcpSession>) {
1209        let params = object(vec![
1210            (
1211                "cwd",
1212                JsonValue::String(std::env::current_dir().unwrap().display().to_string()),
1213            ),
1214            ("mcpServers", JsonValue::Array(Vec::new())),
1215        ]);
1216        let Dispatch::Message(response) = server.dispatch(&request(2, "session/new", params))
1217        else {
1218            panic!("new session response");
1219        };
1220        let response: JsonValue = json_from_str(&response).unwrap();
1221        let session_id = response
1222            .get("result")
1223            .and_then(|result| result.get("sessionId"))
1224            .and_then(JsonValue::as_str)
1225            .unwrap()
1226            .to_string();
1227        let session = server
1228            .state
1229            .lock()
1230            .sessions
1231            .get(&session_id)
1232            .cloned()
1233            .unwrap();
1234        (session_id, session)
1235    }
1236
1237    fn new_v2_session(server: &AcpServer) -> (String, Arc<AcpSession>) {
1238        let params = object(vec![(
1239            "cwd",
1240            JsonValue::String(std::env::current_dir().unwrap().display().to_string()),
1241        )]);
1242        let Dispatch::Message(response) = server.dispatch(&request(2, "session/new", params))
1243        else {
1244            panic!("new session response");
1245        };
1246        let response: JsonValue = json_from_str(&response).unwrap();
1247        let session_id = response
1248            .get("result")
1249            .and_then(|result| result.get("sessionId"))
1250            .and_then(JsonValue::as_str)
1251            .unwrap()
1252            .to_string();
1253        let session = server
1254            .state
1255            .lock()
1256            .sessions
1257            .get(&session_id)
1258            .cloned()
1259            .unwrap();
1260        (session_id, session)
1261    }
1262
1263    #[test]
1264    fn initialize_advertises_only_implemented_capabilities() {
1265        let server = AcpServer::new(RedDBRuntime::in_memory().unwrap());
1266        let Dispatch::Message(response) = server.dispatch(&request(
1267            1,
1268            "initialize",
1269            object(vec![("protocolVersion", JsonValue::Integer(1))]),
1270        )) else {
1271            panic!("initialize response");
1272        };
1273        let response: JsonValue = json_from_str(&response).unwrap();
1274        let result = response.get("result").unwrap();
1275        assert_eq!(
1276            result.get("protocolVersion").and_then(JsonValue::as_i64),
1277            Some(1)
1278        );
1279        assert!(result
1280            .get("authMethods")
1281            .and_then(JsonValue::as_array)
1282            .unwrap()
1283            .is_empty());
1284        assert!(result
1285            .get("agentCapabilities")
1286            .and_then(|capabilities| capabilities.get("auth"))
1287            .and_then(JsonValue::as_object)
1288            .unwrap()
1289            .is_empty());
1290        assert!(result
1291            .get("agentCapabilities")
1292            .and_then(|capabilities| capabilities.get("sessionCapabilities"))
1293            .and_then(|sessions| sessions.get("close"))
1294            .is_some());
1295    }
1296
1297    #[test]
1298    fn initialize_negotiates_v2_and_rejects_unsupported_versions() {
1299        let server = AcpServer::new(RedDBRuntime::in_memory().unwrap());
1300        let Dispatch::Message(missing_info) = server.dispatch(&request(
1301            1,
1302            "initialize",
1303            object(vec![("protocolVersion", JsonValue::Integer(2))]),
1304        )) else {
1305            panic!("initialize response");
1306        };
1307        assert!(missing_info.contains("requires info.name and info.version"));
1308
1309        let Dispatch::Message(response) = server.dispatch(&request(
1310            2,
1311            "initialize",
1312            object(vec![
1313                (
1314                    "info",
1315                    object(vec![
1316                        ("name", JsonValue::String("test-client".into())),
1317                        ("version", JsonValue::String("1.0.0".into())),
1318                    ]),
1319                ),
1320                ("protocolVersion", JsonValue::Integer(9)),
1321            ]),
1322        )) else {
1323            panic!("initialize response");
1324        };
1325        let response: JsonValue = json_from_str(&response).unwrap();
1326        let result = response.get("result").unwrap();
1327        assert_eq!(
1328            result.get("protocolVersion").and_then(JsonValue::as_i64),
1329            Some(2)
1330        );
1331        assert!(result
1332            .get("capabilities")
1333            .and_then(|capabilities| capabilities.get("session"))
1334            .and_then(JsonValue::as_object)
1335            .unwrap()
1336            .is_empty());
1337        assert!(result.get("agentCapabilities").is_none());
1338
1339        let unsupported = AcpServer::new(RedDBRuntime::in_memory().unwrap());
1340        let Dispatch::Message(response) = unsupported.dispatch(&request(
1341            1,
1342            "initialize",
1343            object(vec![("protocolVersion", JsonValue::Integer(0))]),
1344        )) else {
1345            panic!("initialize response");
1346        };
1347        assert!(response.contains("-32022"));
1348
1349        let null_id = AcpServer::new(RedDBRuntime::in_memory().unwrap());
1350        let message = object(vec![
1351            ("id", JsonValue::Null),
1352            ("jsonrpc", JsonValue::String("2.0".into())),
1353            ("method", JsonValue::String("initialize".into())),
1354            (
1355                "params",
1356                object(vec![
1357                    (
1358                        "info",
1359                        object(vec![
1360                            ("name", JsonValue::String("test-client".into())),
1361                            ("version", JsonValue::String("1.0.0".into())),
1362                        ]),
1363                    ),
1364                    ("protocolVersion", JsonValue::Integer(2)),
1365                ]),
1366            ),
1367        ]);
1368        let Dispatch::Message(response) = null_id.dispatch(&message) else {
1369            panic!("initialize response");
1370        };
1371        assert!(response.contains("must not be null"));
1372    }
1373
1374    #[test]
1375    fn v2_baseline_lists_and_resumes_connection_sessions() {
1376        let server = initialized_v2_server();
1377        let (session_id, session) = new_v2_session(&server);
1378
1379        let Dispatch::Message(response) =
1380            server.dispatch(&request(3, "session/list", object(Vec::new())))
1381        else {
1382            panic!("list response");
1383        };
1384        let response: JsonValue = json_from_str(&response).unwrap();
1385        let sessions = response
1386            .get("result")
1387            .and_then(|result| result.get("sessions"))
1388            .and_then(JsonValue::as_array)
1389            .unwrap();
1390        assert_eq!(sessions.len(), 1);
1391        assert_eq!(
1392            sessions[0].get("sessionId").and_then(JsonValue::as_str),
1393            Some(session_id.as_str())
1394        );
1395
1396        let params = object(vec![
1397            ("cwd", JsonValue::String(session.cwd.clone())),
1398            ("sessionId", JsonValue::String(session_id)),
1399        ]);
1400        let Dispatch::Messages(messages) = server.dispatch(&request(4, "session/resume", params))
1401        else {
1402            panic!("resume response");
1403        };
1404        assert_eq!(messages.len(), 2);
1405        assert!(messages[0].contains("state_update"));
1406        assert!(messages[0].contains("idle"));
1407        assert!(messages[1].contains("\"result\":{}"));
1408    }
1409
1410    #[test]
1411    fn session_creation_requires_initialize_and_rejects_external_mcp() {
1412        let runtime = RedDBRuntime::in_memory().unwrap();
1413        let server = AcpServer::new(runtime);
1414        let params = object(vec![
1415            (
1416                "cwd",
1417                JsonValue::String(std::env::current_dir().unwrap().display().to_string()),
1418            ),
1419            ("mcpServers", JsonValue::Array(Vec::new())),
1420        ]);
1421        let Dispatch::Message(response) = server.dispatch(&request(1, "session/new", params))
1422        else {
1423            panic!("new session response");
1424        };
1425        assert!(response.contains("initialize must be called first"));
1426
1427        let server = initialized_server();
1428        let params = object(vec![
1429            (
1430                "cwd",
1431                JsonValue::String(std::env::current_dir().unwrap().display().to_string()),
1432            ),
1433            ("mcpServers", JsonValue::Array(vec![object(Vec::new())])),
1434        ]);
1435        let Dispatch::Message(response) = server.dispatch(&request(2, "session/new", params))
1436        else {
1437            panic!("new session response");
1438        };
1439        assert!(response.contains("external MCP servers are not supported"));
1440    }
1441
1442    #[test]
1443    fn prompt_accepts_text_and_baseline_resource_links() {
1444        let params = object(vec![(
1445            "prompt",
1446            JsonValue::Array(vec![
1447                object(vec![
1448                    ("text", JsonValue::String("Find the owner".into())),
1449                    ("type", JsonValue::String("text".into())),
1450                ]),
1451                object(vec![
1452                    ("name", JsonValue::String("passport".into())),
1453                    ("type", JsonValue::String("resource_link".into())),
1454                    ("uri", JsonValue::String("reddb://people/123".into())),
1455                ]),
1456            ]),
1457        )]);
1458        assert_eq!(
1459            prompt_text(&params).unwrap(),
1460            "Find the owner\n\nReferenced resource: passport (reddb://people/123)"
1461        );
1462    }
1463
1464    #[test]
1465    fn session_captures_identity_but_not_a_stale_snapshot() {
1466        struct ResetIdentity;
1467        impl Drop for ResetIdentity {
1468            fn drop(&mut self) {
1469                crate::runtime::impl_core::clear_current_auth_identity();
1470                crate::runtime::impl_core::clear_current_tenant();
1471            }
1472        }
1473        let _reset = ResetIdentity;
1474        crate::runtime::impl_core::set_current_tenant("tenant-a".into());
1475        execution_context::set_current_auth_principal(
1476            crate::auth::UserId::scoped("tenant-a", "alice"),
1477            crate::auth::Role::Read,
1478        );
1479
1480        let server = initialized_server();
1481        let (_, session) = new_session(&server);
1482
1483        crate::runtime::impl_core::set_current_tenant("tenant-b".into());
1484        execution_context::set_current_auth_principal(
1485            crate::auth::UserId::scoped("tenant-b", "bob"),
1486            crate::auth::Role::Admin,
1487        );
1488        assert_eq!(session.identity.tenant.as_deref(), Some("tenant-a"));
1489        assert_eq!(
1490            session
1491                .identity
1492                .auth
1493                .as_ref()
1494                .map(|(user, role)| (user.as_str(), *role)),
1495            Some(("alice", crate::auth::Role::Read))
1496        );
1497        assert_eq!(
1498            session
1499                .identity
1500                .auth_principal
1501                .as_ref()
1502                .map(|principal| principal.username.as_str()),
1503            Some("alice")
1504        );
1505        assert!(session.identity.snapshot.is_none());
1506
1507        let scope = execution_context::with_snapshot_bundle(&session.identity, || {
1508            server.runtime.ai_scope()
1509        });
1510        assert_eq!(scope.tenant.as_deref(), Some("tenant-a"));
1511        assert_eq!(
1512            scope
1513                .identity
1514                .as_ref()
1515                .map(|(user, role)| (user.as_str(), *role)),
1516            Some(("alice", crate::auth::Role::Read))
1517        );
1518        assert_eq!(
1519            scope
1520                .auth_principal
1521                .as_ref()
1522                .map(|principal| (principal.tenant.as_deref(), principal.username.as_str())),
1523            Some((Some("tenant-a"), "alice"))
1524        );
1525    }
1526
1527    #[test]
1528    fn cancel_notification_reaches_the_active_query_token() {
1529        let server = initialized_server();
1530        let (session_id, session) = new_session(&server);
1531        let prompt = object(vec![
1532            (
1533                "prompt",
1534                JsonValue::Array(vec![object(vec![
1535                    ("text", JsonValue::String("Who is active?".into())),
1536                    ("type", JsonValue::String("text".into())),
1537                ])]),
1538            ),
1539            ("sessionId", JsonValue::String(session_id.clone())),
1540        ]);
1541        assert!(matches!(
1542            server.dispatch(&request(3, "session/prompt", prompt)),
1543            Dispatch::Prompt { .. }
1544        ));
1545
1546        let cancel = object(vec![
1547            ("jsonrpc", JsonValue::String("2.0".into())),
1548            ("method", JsonValue::String("session/cancel".into())),
1549            (
1550                "params",
1551                object(vec![("sessionId", JsonValue::String(session_id))]),
1552            ),
1553        ]);
1554        assert!(matches!(server.dispatch(&cancel), Dispatch::None));
1555        assert!(session
1556            .active
1557            .lock()
1558            .as_ref()
1559            .unwrap()
1560            .cancelled
1561            .load(Ordering::Acquire));
1562    }
1563
1564    #[test]
1565    fn ask_trace_becomes_thought_tools_and_answer_updates() {
1566        let step = object(vec![
1567            ("purpose", JsonValue::String("Validate the answer".into())),
1568            (
1569                "sql",
1570                JsonValue::String("SELECT name FROM people WHERE passport = $1".into()),
1571            ),
1572            ("tool", JsonValue::String("reddb_final_query".into())),
1573        ]);
1574        let envelope = object(vec![
1575            ("answer", JsonValue::String("Ana is active.".into())),
1576            (
1577                "reasoning",
1578                object(vec![
1579                    ("steps", JsonValue::Array(vec![step])),
1580                    ("summary", JsonValue::String("Located the person.".into())),
1581                ]),
1582            ),
1583        ]);
1584        let next_tool_call = AtomicU64::new(1);
1585        let messages = turn_messages_v1("s-1", &envelope, &next_tool_call);
1586        assert_eq!(messages.len(), 3);
1587        assert!(messages[0].contains("agent_thought_chunk"));
1588        assert!(messages[1].contains("reddb_final_query"));
1589        assert!(messages[1].contains("SELECT name FROM people"));
1590        assert!(messages[2].contains("Ana is active."));
1591
1592        let next_messages = turn_messages_v1("s-1", &envelope, &next_tool_call);
1593        assert!(messages[1].contains("reddb-1"));
1594        assert!(next_messages[1].contains("reddb-2"));
1595    }
1596
1597    #[test]
1598    fn v2_prompt_acknowledges_insertion_and_uses_upsert_updates() {
1599        let server = initialized_v2_server();
1600        let (session_id, session) = new_v2_session(&server);
1601        let prompt = object(vec![
1602            (
1603                "prompt",
1604                JsonValue::Array(vec![object(vec![
1605                    ("text", JsonValue::String("Who is active?".into())),
1606                    ("type", JsonValue::String("text".into())),
1607                ])]),
1608            ),
1609            ("sessionId", JsonValue::String(session_id.clone())),
1610        ]);
1611        let Dispatch::Prompt { job, accepted } =
1612            server.dispatch(&request(3, "session/prompt", prompt))
1613        else {
1614            panic!("prompt job");
1615        };
1616        let accepted: JsonValue = json_from_str(&accepted.unwrap()).unwrap();
1617        assert_eq!(
1618            accepted
1619                .get("result")
1620                .and_then(|result| result.get("messageId"))
1621                .and_then(JsonValue::as_str),
1622            job.user_message_id.as_deref()
1623        );
1624        assert_eq!(job.protocol_version, ProtocolVersion::V2);
1625
1626        let step = object(vec![
1627            ("purpose", JsonValue::String("Validate the answer".into())),
1628            ("tool", JsonValue::String("reddb_final_query".into())),
1629        ]);
1630        let envelope = object(vec![
1631            ("answer", JsonValue::String("Ana is active.".into())),
1632            (
1633                "reasoning",
1634                object(vec![
1635                    ("steps", JsonValue::Array(vec![step])),
1636                    ("summary", JsonValue::String("Located the person.".into())),
1637                ]),
1638            ),
1639        ]);
1640        let messages = turn_messages_v2(&session_id, &envelope, &session);
1641        assert_eq!(messages.len(), 3);
1642        assert!(messages[0].contains("agent_thought"));
1643        assert!(messages[0].contains("messageId"));
1644        assert!(messages[1].contains("tool_call_update"));
1645        assert!(messages[1].contains("toolCallId"));
1646        assert!(messages[2].contains("agent_message"));
1647        assert!(v2_state_update(&session_id, "idle", Some("end_turn"), None).contains("stopReason"));
1648    }
1649}