1use 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 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 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(¶ms).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}