1use std::collections::HashMap;
68use std::sync::Arc;
69
70use serde::Deserialize;
71use serde_json::Value;
72use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
73use tokio::sync::Mutex;
74use tracing::debug;
75
76use super::bridge::sha256_first_12;
77use super::bridge::AcpBridge;
78use super::permission::handle_request_permission;
79use super::protocol::{ClientCapabilities, Implementation, ProtocolVersion};
80use super::session::{
81 compute_compaction_hints, summarize_transcript, AcpSession, AcpSessionManager,
82};
83use crate::acp::ToolKind;
84use crate::agent::FinishReason;
85use crate::llm::ChatProvider;
86use crate::runtime::AgentRuntime;
87use crate::tools::{AcpClientFsState, ClientReadFile, ClientWriteFile};
88
89const PARSE_ERROR: i32 = -32700;
94const INVALID_REQUEST: i32 = -32600;
95const METHOD_NOT_FOUND: i32 = -32601;
96const INVALID_PARAMS: i32 = -32602;
97const SERVER_NOT_INITIALIZED: i32 = -32002;
98const SESSION_NOT_FOUND: i32 = -32000;
100
101#[inline]
105pub fn client_rpc_timeout_ms() -> u64 {
106 std::env::var("ACP_CLIENT_RPC_TIMEOUT_MS")
107 .ok()
108 .and_then(|v| v.parse::<u64>().ok())
109 .unwrap_or(30_000)
110}
111
112const SUPPORTED_PROTOCOL_VERSION: ProtocolVersion = ProtocolVersion::V1;
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121enum ServerState {
122 Uninitialized,
124 Initialized,
126}
127
128#[derive(Debug, Deserialize)]
134struct JsonRpcEnvelope {
135 #[serde(default)]
136 jsonrpc: Option<String>,
137 #[serde(default)]
138 id: Option<Value>,
139 #[serde(default)]
140 method: Option<String>,
141 #[serde(default)]
142 params: Option<Value>,
143}
144
145fn build_success(id: &Value, result: Value) -> Value {
151 serde_json::json!({
152 "jsonrpc": "2.0",
153 "id": id,
154 "result": result,
155 })
156}
157
158fn build_error(id: &Value, code: i32, message: &str) -> Value {
160 serde_json::json!({
161 "jsonrpc": "2.0",
162 "id": id,
163 "error": {
164 "code": code,
165 "message": message,
166 },
167 })
168}
169
170async fn write_line<W: tokio::io::AsyncWrite + Unpin>(
172 writer: &mut W,
173 value: &Value,
174) -> Result<(), std::io::Error> {
175 let json = serde_json::to_string(value).unwrap_or_default();
176 writer.write_all(json.as_bytes()).await?;
177 writer.write_all(b"\n").await?;
178 writer.flush().await?;
179 Ok(())
180}
181
182type SyncMethodHandler = fn(id: &Value, params: Option<&Value>) -> Value;
188
189fn build_sync_dispatch_table() -> HashMap<&'static str, SyncMethodHandler> {
193 let mut map: HashMap<&'static str, SyncMethodHandler> = HashMap::new();
194 map.insert("initialize", handle_initialize);
195 map
196}
197
198async fn dispatch_async(
212 method: &str,
213 id: &Value,
214 params: Option<&Value>,
215 state: &mut ServerState,
216 sessions: &mut AcpSessionManager,
217 llm: Option<&Arc<dyn ChatProvider>>,
218) -> (Option<Value>, Vec<Value>) {
219 match method {
220 "session/new" => match *state {
222 ServerState::Uninitialized => (
223 Some(build_error(
224 id,
225 SERVER_NOT_INITIALIZED,
226 "Server not initialized",
227 )),
228 vec![],
229 ),
230 ServerState::Initialized => match llm {
231 Some(llm) => handle_session_new(id, params, sessions, llm).await,
232 None => (
233 Some(build_error(
234 id,
235 METHOD_NOT_FOUND,
236 &format!("Method not found: {method}"),
237 )),
238 vec![],
239 ),
240 },
241 },
242
243 "session/prompt" => match *state {
245 ServerState::Uninitialized => (
246 Some(build_error(
247 id,
248 SERVER_NOT_INITIALIZED,
249 "Server not initialized",
250 )),
251 vec![],
252 ),
253 ServerState::Initialized => match llm {
254 Some(_llm) => handle_session_prompt(id, params, sessions).await,
255 None => (
256 Some(build_error(
257 id,
258 METHOD_NOT_FOUND,
259 &format!("Method not found: {method}"),
260 )),
261 vec![],
262 ),
263 },
264 },
265
266 "session/cancel" => match *state {
268 ServerState::Uninitialized => (
269 Some(build_error(
270 id,
271 SERVER_NOT_INITIALIZED,
272 "Server not initialized",
273 )),
274 vec![],
275 ),
276 ServerState::Initialized => handle_session_cancel(id, params, sessions),
277 },
278
279 "session/load" => match *state {
281 ServerState::Uninitialized => (
282 Some(build_error(
283 id,
284 SERVER_NOT_INITIALIZED,
285 "Server not initialized",
286 )),
287 vec![],
288 ),
289 ServerState::Initialized => match llm {
290 Some(llm) => handle_session_load(id, params, sessions, llm).await,
291 None => (
292 Some(build_error(
293 id,
294 METHOD_NOT_FOUND,
295 &format!("Method not found: {method}"),
296 )),
297 vec![],
298 ),
299 },
300 },
301
302 "session/resume" => match *state {
304 ServerState::Uninitialized => (
305 Some(build_error(
306 id,
307 SERVER_NOT_INITIALIZED,
308 "Server not initialized",
309 )),
310 vec![],
311 ),
312 ServerState::Initialized => match llm {
313 Some(llm) => handle_session_resume(id, params, sessions, llm).await,
314 None => (
315 Some(build_error(
316 id,
317 METHOD_NOT_FOUND,
318 &format!("Method not found: {method}"),
319 )),
320 vec![],
321 ),
322 },
323 },
324
325 "session/request_permission" => match *state {
327 ServerState::Uninitialized => (
328 Some(build_error(
329 id,
330 SERVER_NOT_INITIALIZED,
331 "Server not initialized",
332 )),
333 vec![],
334 ),
335 ServerState::Initialized => {
336 let resp = handle_request_permission(id, params, sessions.permission_store());
337 (Some(resp), vec![])
338 }
339 },
340
341 "session/close" => match *state {
343 ServerState::Uninitialized => (
344 Some(build_error(
345 id,
346 SERVER_NOT_INITIALIZED,
347 "Server not initialized",
348 )),
349 vec![],
350 ),
351 ServerState::Initialized => handle_session_close(id, params, sessions).await,
352 },
353
354 _ => (
355 Some(build_error(
356 id,
357 METHOD_NOT_FOUND,
358 &format!("Method not found: {method}"),
359 )),
360 vec![],
361 ),
362 }
363}
364
365fn handle_initialize(id: &Value, params: Option<&Value>) -> Value {
373 if let Some(params) = params {
374 match params.get("protocolVersion") {
375 None => {
376 return build_error(
377 id,
378 INVALID_PARAMS,
379 "Missing required field 'protocolVersion'",
380 );
381 }
382 Some(v) if !v.is_number() => {
383 return build_error(
384 id,
385 INVALID_PARAMS,
386 "Invalid params: 'protocolVersion' must be a number",
387 );
388 }
389 Some(v) => {
390 let version: u16 = v.as_u64().unwrap_or(0) as u16;
391 if version != SUPPORTED_PROTOCOL_VERSION.as_u16() {
392 return build_error(
393 id,
394 INVALID_PARAMS,
395 &format!(
396 "Unsupported protocol version {version}; server supports {SUPPORTED_PROTOCOL_VERSION}",
397 ),
398 );
399 }
400 }
401 }
402 }
403
404 let agent_info = Implementation::new("recursive", env!("CARGO_PKG_VERSION"));
405
406 let agent_capabilities = serde_json::json!({
411 "promptCapabilities": {},
412 "toolCallNotifications": {},
413 "loadSession": {},
414 "resume": {},
415 "fs.readTextFile": {},
416 "fs.writeTextFile": {},
417 "mcpCapabilities": {},
418 "terminalCapabilities": false,
419 });
420
421 let result = serde_json::json!({
422 "protocolVersion": SUPPORTED_PROTOCOL_VERSION.as_u16(),
423 "agentInfo": agent_info,
424 "agentCapabilities": agent_capabilities,
425 });
426
427 build_success(id, result)
428}
429
430async fn handle_session_new(
440 id: &Value,
441 params: Option<&Value>,
442 sessions: &mut AcpSessionManager,
443 llm: &Arc<dyn ChatProvider>,
444) -> (Option<Value>, Vec<Value>) {
445 let params = match params {
446 Some(p) => p,
447 None => {
448 return (
449 Some(build_error(
450 id,
451 INVALID_PARAMS,
452 "Missing required field 'cwd'",
453 )),
454 vec![],
455 );
456 }
457 };
458
459 let cwd_str = match params.get("cwd").and_then(|v| v.as_str()) {
461 Some(s) => s,
462 None => {
463 return (
464 Some(build_error(
465 id,
466 INVALID_PARAMS,
467 "Missing required field 'cwd'",
468 )),
469 vec![],
470 );
471 }
472 };
473
474 let cwd = match std::path::PathBuf::from(cwd_str).canonicalize() {
477 Ok(p) => {
478 if !p.is_dir() {
480 return (
481 Some(build_error(
482 id,
483 INVALID_PARAMS,
484 &format!("cwd is not a directory: {cwd_str}"),
485 )),
486 vec![],
487 );
488 }
489 p
490 }
491 Err(_) => {
492 return (
493 Some(build_error(
494 id,
495 INVALID_PARAMS,
496 &format!("cwd does not exist: {cwd_str}"),
497 )),
498 vec![],
499 );
500 }
501 };
502
503 let sandbox_root = std::env::var("RECURSIVE_ACP_SANDBOX_ROOT")
515 .ok()
516 .map(std::path::PathBuf::from)
517 .or_else(|| std::env::current_dir().ok())
518 .and_then(|p| p.canonicalize().ok());
519 if let Some(root) = sandbox_root {
520 let in_sandbox = cwd == root || cwd.starts_with(&root);
521 if !in_sandbox {
522 if std::env::var("RECURSIVE_ACP_SANDBOX_STRICT").is_ok() {
523 return (
524 Some(build_error(
525 id,
526 INVALID_PARAMS,
527 &format!("cwd escapes sandbox root: {cwd_str}"),
528 )),
529 vec![],
530 );
531 }
532 tracing::warn!(
533 cwd = %cwd_str,
534 sandbox_root = %root.display(),
535 "AC-2.1: cwd outside sandbox root but strict mode is off; set RECURSIVE_ACP_SANDBOX_STRICT=1 to enforce"
536 );
537 }
538 }
539
540 let session_id = sessions.next_session_id();
542
543 let (bridge, _rx) = AcpBridge::new(
545 session_id.clone(),
546 std::collections::HashMap::new(),
547 0,
548 false,
549 );
550
551 let runtime = match AgentRuntime::builder()
553 .llm(llm.clone())
554 .event_sink(bridge)
555 .streaming(true)
556 .build()
557 {
558 Ok(rt) => rt,
559 Err(e) => {
560 return (
561 Some(build_error(
562 id,
563 INVALID_PARAMS,
564 &format!("Failed to create agent runtime: {e}"),
565 )),
566 vec![],
567 );
568 }
569 };
570
571 let system_prompt = params
573 .get("systemPrompt")
574 .and_then(|v| v.as_str())
575 .map(|s| s.to_string());
576 let mcp_servers = params.get("mcpServers").cloned();
577
578 let acp_state = Arc::new(Mutex::new(AcpClientFsState::new()));
582 if let Some(caps) = sessions.get_client_capabilities() {
583 let fs_caps = &caps.fs;
584 let mut state = acp_state.lock().await;
585 state.read_text_file = fs_caps.read_text_file;
586 state.write_text_file = fs_caps.write_text_file;
587 tracing::info!(
588 read_text_file = state.read_text_file,
589 write_text_file = state.write_text_file,
590 "ACP session: configured client FS capabilities"
591 );
592 }
593 let client_capabilities = sessions.get_client_capabilities().cloned();
594
595 let cancel_token = Arc::new(tokio_util::sync::CancellationToken::new());
597 let session = AcpSession {
598 runtime,
599 cwd: cwd.clone(),
600 turn: 0,
601 session_id: session_id.clone(),
602 transcript: Vec::new(),
603 cancel_token,
604 system_prompt,
605 mcp_servers,
606 client_capabilities,
607 acp_state: acp_state.clone(),
608 mcp_child_pids: Vec::new(),
609 };
610 sessions.insert_with_id(session_id.clone(), session);
611
612 {
615 let state = acp_state.lock().await;
616 if state.read_text_file || state.write_text_file {
617 let workspace = std::path::Path::new(&cwd);
619 if state.read_text_file {
620 let _tool = Arc::new(
621 ClientReadFile::new(workspace)
622 .with_acp_state(acp_state.clone())
623 .with_client_read_timeout(client_rpc_timeout_ms()),
624 );
625 tracing::info!("ACP session: ClientReadFile tool ready");
629 }
630 if state.write_text_file {
631 let _tool =
632 Arc::new(ClientWriteFile::new(workspace).with_acp_state(acp_state.clone()));
633 tracing::info!("ACP session: ClientWriteFile tool ready");
634 }
635 }
636 }
637
638 let result = serde_json::json!({
639 "sessionId": session_id,
640 "capabilities": {},
641 });
642
643 (Some(build_success(id, result)), vec![])
644}
645
646async fn handle_session_load(
665 id: &Value,
666 params: Option<&Value>,
667 sessions: &mut AcpSessionManager,
668 llm: &Arc<dyn ChatProvider>,
669) -> (Option<Value>, Vec<Value>) {
670 let params = match params {
671 Some(p) => p,
672 None => {
673 return (
674 Some(build_error(
675 id,
676 INVALID_PARAMS,
677 "Missing required field 'sessionId'",
678 )),
679 vec![],
680 );
681 }
682 };
683
684 let sid = match params.get("sessionId").and_then(|v| v.as_str()) {
686 Some(s) => s.to_string(),
687 None => {
688 return (
689 Some(build_error(
690 id,
691 INVALID_PARAMS,
692 "Missing required field 'sessionId'",
693 )),
694 vec![],
695 );
696 }
697 };
698
699 if !sessions.session_exists_on_disk(&sid) {
701 return (
702 Some(build_error(
703 id,
704 SESSION_NOT_FOUND,
705 &format!("Session not found: {sid}"),
706 )),
707 vec![],
708 );
709 }
710
711 let saved_messages = match sessions.load_transcript_from_disk(&sid) {
713 Ok(msgs) => msgs,
714 Err(e) => {
715 return (
716 Some(build_error(
717 id,
718 SESSION_NOT_FOUND,
719 &format!("Failed to parse session transcript: {e}"),
720 )),
721 vec![],
722 );
723 }
724 };
725
726 let metadata = match sessions.load_metadata_from_disk(&sid) {
727 Ok(m) => m,
728 Err(e) => {
729 return (
730 Some(build_error(
731 id,
732 SESSION_NOT_FOUND,
733 &format!("Failed to parse session metadata: {e}"),
734 )),
735 vec![],
736 );
737 }
738 };
739
740 let messages: Vec<crate::message::Message> =
742 saved_messages.iter().map(|sm| sm.message.clone()).collect();
743 let summarized = summarize_transcript(&messages);
744
745 let system_prompt_with_summary = match &metadata.system_prompt {
747 Some(sp) => format!("{}\n\n[Session context]: {}", sp, summarized.summary),
748 None => format!("[Session context]: {}", summarized.summary),
749 };
750
751 let mcp_servers = params
753 .get("mcpServers")
754 .cloned()
755 .or(metadata.mcp_servers.clone());
756
757 let turn = metadata.turn;
759 let (bridge, _rx) = AcpBridge::new(
760 sid.clone(),
761 std::collections::HashMap::new(),
762 turn,
763 true, );
765
766 let runtime = match AgentRuntime::builder()
767 .llm(llm.clone())
768 .event_sink(bridge)
769 .streaming(true)
770 .system_prompt(system_prompt_with_summary.clone())
771 .build()
772 {
773 Ok(rt) => rt,
774 Err(e) => {
775 return (
776 Some(build_error(
777 id,
778 INVALID_PARAMS,
779 &format!("Failed to create agent runtime: {e}"),
780 )),
781 vec![],
782 );
783 }
784 };
785
786 let cancel_token = Arc::new(tokio_util::sync::CancellationToken::new());
788
789 if let Some(old_session) = sessions.get_mut(&sid) {
791 old_session.kill_mcp_children().await;
792 tracing::info!(session = %sid, "session/load: killed old MCP children");
793 }
794
795 let session = AcpSession {
796 runtime,
797 cwd: metadata.cwd.clone(),
798 turn,
799 session_id: sid.clone(),
800 transcript: messages,
801 cancel_token,
802 system_prompt: metadata.system_prompt,
803 mcp_servers,
804 client_capabilities: None,
805 acp_state: Arc::new(Mutex::new(AcpClientFsState::new())),
806 mcp_child_pids: Vec::new(),
807 };
808 sessions.insert_with_id(sid.clone(), session);
809
810 let mut notifications: Vec<Value> = Vec::new();
814
815 for sm in &saved_messages {
816 match sm.message.role {
817 crate::message::Role::User => {
818 let notif = serde_json::json!({
819 "jsonrpc": "2.0",
820 "method": "session/update",
821 "params": {
822 "sessionId": sid,
823 "update": {
824 "sessionUpdate": "user_message_chunk",
825 "messageId": sm.id,
826 "content": {
827 "type": "text",
828 "text": sm.message.content,
829 }
830 }
831 }
832 });
833 notifications.push(notif);
834 }
835 crate::message::Role::Assistant => {
836 for tc in &sm.message.tool_calls {
839 let tool_call_notif = serde_json::json!({
840 "jsonrpc": "2.0",
841 "method": "session/update",
842 "params": {
843 "sessionId": sid,
844 "update": {
845 "sessionUpdate": "tool_call",
846 "toolCallId": tc.id,
847 "title": tc.name,
848 "kind": ToolKind::from_acp_tool_name(&tc.name),
849 "status": "pending",
850 }
851 }
852 });
853 notifications.push(tool_call_notif);
854
855 let in_progress_notif = serde_json::json!({
856 "jsonrpc": "2.0",
857 "method": "session/update",
858 "params": {
859 "sessionId": sid,
860 "update": {
861 "sessionUpdate": "tool_call_update",
862 "toolCallId": tc.id,
863 "status": "in_progress",
864 }
865 }
866 });
867 notifications.push(in_progress_notif);
868 }
869
870 let notif = serde_json::json!({
871 "jsonrpc": "2.0",
872 "method": "session/update",
873 "params": {
874 "sessionId": sid,
875 "update": {
876 "sessionUpdate": "agent_message_chunk",
877 "messageId": sm.id,
878 "content": {
879 "type": "text",
880 "text": sm.message.content,
881 }
882 }
883 }
884 });
885 notifications.push(notif);
886 }
887 crate::message::Role::Tool => {
888 let tc_id = sm.message.tool_call_id.as_deref().unwrap_or(&sm.id);
892 let notif = serde_json::json!({
893 "jsonrpc": "2.0",
894 "method": "session/update",
895 "params": {
896 "sessionId": sid,
897 "update": {
898 "sessionUpdate": "tool_call_update",
899 "toolCallId": tc_id,
900 "status": "completed",
901 "content": {
902 "type": "text",
903 "text": sm.message.content,
904 }
905 }
906 }
907 });
908 notifications.push(notif);
909 }
910 crate::message::Role::System => {
911 }
913 }
914 }
915
916 let loaded_notif = serde_json::json!({
918 "jsonrpc": "2.0",
919 "method": "session/update",
920 "params": {
921 "sessionId": sid,
922 "update": {
923 "sessionUpdate": "session/loaded",
924 "messageCount": saved_messages.len(),
925 }
926 }
927 });
928 notifications.push(loaded_notif);
929
930 (
932 Some(build_success(id, serde_json::Value::Null)),
933 notifications,
934 )
935}
936
937async fn handle_session_resume(
954 id: &Value,
955 params: Option<&Value>,
956 sessions: &mut AcpSessionManager,
957 llm: &Arc<dyn ChatProvider>,
958) -> (Option<Value>, Vec<Value>) {
959 let params = match params {
960 Some(p) => p,
961 None => {
962 return (
963 Some(build_error(
964 id,
965 INVALID_PARAMS,
966 "Missing required field 'sessionId'",
967 )),
968 vec![],
969 );
970 }
971 };
972
973 let sid = match params.get("sessionId").and_then(|v| v.as_str()) {
975 Some(s) => s.to_string(),
976 None => {
977 return (
978 Some(build_error(
979 id,
980 INVALID_PARAMS,
981 "Missing required field 'sessionId'",
982 )),
983 vec![],
984 );
985 }
986 };
987
988 if !sessions.session_exists_on_disk(&sid) {
990 return (
991 Some(build_error(
992 id,
993 SESSION_NOT_FOUND,
994 &format!("Session not found: {sid}"),
995 )),
996 vec![],
997 );
998 }
999
1000 let saved_messages = match sessions.load_transcript_from_disk(&sid) {
1002 Ok(msgs) => msgs,
1003 Err(e) => {
1004 return (
1005 Some(build_error(
1006 id,
1007 SESSION_NOT_FOUND,
1008 &format!("Failed to parse session transcript: {e}"),
1009 )),
1010 vec![],
1011 );
1012 }
1013 };
1014
1015 let metadata = match sessions.load_metadata_from_disk(&sid) {
1017 Ok(m) => m,
1018 Err(e) => {
1019 return (
1020 Some(build_error(
1021 id,
1022 SESSION_NOT_FOUND,
1023 &format!("Failed to parse session metadata: {e}"),
1024 )),
1025 vec![],
1026 );
1027 }
1028 };
1029
1030 let mcp_servers = params
1033 .get("mcpServers")
1034 .cloned()
1035 .or(metadata.mcp_servers.clone());
1036
1037 let messages: Vec<crate::message::Message> =
1039 saved_messages.iter().map(|sm| sm.message.clone()).collect();
1040
1041 let hints = compute_compaction_hints(&saved_messages, 2);
1043 let hint_values: Vec<serde_json::Value> = hints
1044 .iter()
1045 .map(|h| {
1046 serde_json::json!({
1047 "turnIndex": h.turn_index,
1048 "compressible": h.compressible,
1049 })
1050 })
1051 .collect();
1052
1053 let (bridge, _rx) = AcpBridge::new(
1055 sid.clone(),
1056 std::collections::HashMap::new(),
1057 metadata.turn,
1058 true,
1059 );
1060
1061 let runtime = match AgentRuntime::builder()
1062 .llm(llm.clone())
1063 .event_sink(bridge)
1064 .streaming(true)
1065 .system_prompt(metadata.system_prompt.clone().unwrap_or_default())
1066 .seed_transcript(messages.clone())
1067 .build()
1068 {
1069 Ok(rt) => rt,
1070 Err(e) => {
1071 return (
1072 Some(build_error(
1073 id,
1074 INVALID_PARAMS,
1075 &format!("Failed to create agent runtime: {e}"),
1076 )),
1077 vec![],
1078 );
1079 }
1080 };
1081
1082 let cancel_token = Arc::new(tokio_util::sync::CancellationToken::new());
1084
1085 if let Some(old_session) = sessions.get_mut(&sid) {
1087 old_session.kill_mcp_children().await;
1088 tracing::info!(session = %sid, "session/resume: killed old MCP children");
1089 }
1090
1091 let session = AcpSession {
1092 runtime,
1093 cwd: metadata.cwd.clone(),
1094 turn: metadata.turn,
1095 session_id: sid.clone(),
1096 transcript: messages,
1097 cancel_token,
1098 system_prompt: metadata.system_prompt.clone(),
1099 mcp_servers,
1100 client_capabilities: None,
1101 acp_state: Arc::new(Mutex::new(AcpClientFsState::new())),
1102 mcp_child_pids: Vec::new(),
1103 };
1104 sessions.insert_with_id(sid.clone(), session);
1105
1106 let result = serde_json::json!({
1108 "sessionId": sid,
1109 "systemPrompt": metadata.system_prompt,
1110 "turn": metadata.turn,
1111 "messageCount": saved_messages.len(),
1112 "compactionHints": hint_values,
1113 });
1114
1115 (Some(build_success(id, result)), vec![])
1116}
1117
1118async fn handle_session_prompt(
1125 id: &Value,
1126 params: Option<&Value>,
1127 sessions: &mut AcpSessionManager,
1128) -> (Option<Value>, Vec<Value>) {
1129 let params = match params {
1130 Some(p) => p,
1131 None => {
1132 return (
1133 Some(build_error(
1134 id,
1135 INVALID_PARAMS,
1136 "Missing required fields 'sessionId' and 'prompt'",
1137 )),
1138 vec![],
1139 );
1140 }
1141 };
1142
1143 let sid = match params.get("sessionId").and_then(|v| v.as_str()) {
1145 Some(s) => s.to_string(),
1146 None => {
1147 return (
1148 Some(build_error(
1149 id,
1150 INVALID_PARAMS,
1151 "Missing required field 'sessionId'",
1152 )),
1153 vec![],
1154 );
1155 }
1156 };
1157
1158 let session = match sessions.get_mut(&sid) {
1160 Some(s) => s,
1161 None => {
1162 return (
1163 Some(build_error(
1164 id,
1165 SESSION_NOT_FOUND,
1166 &format!("Session not found: {sid}"),
1167 )),
1168 vec![],
1169 );
1170 }
1171 };
1172
1173 let prompt_blocks = match params.get("prompt").and_then(|v| v.as_array()) {
1175 Some(arr) => arr,
1176 None => {
1177 return (
1178 Some(build_error(
1179 id,
1180 INVALID_PARAMS,
1181 "Missing required field 'prompt'",
1182 )),
1183 vec![],
1184 );
1185 }
1186 };
1187
1188 let mut concatenated = String::new();
1189 let mut non_text_blocks = Vec::new();
1190
1191 for block in prompt_blocks {
1192 match block.get("type").and_then(|v| v.as_str()) {
1193 Some("text") => {
1194 if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
1195 concatenated.push_str(text);
1196 }
1197 }
1198 Some(other) => {
1199 non_text_blocks.push(other.to_string());
1200 debug!("session/prompt: ignoring unsupported ContentBlock type: {other}");
1201 }
1202 None => {
1203 debug!("session/prompt: ContentBlock missing 'type' field, skipping");
1204 }
1205 }
1206 }
1207
1208 if concatenated.is_empty() && non_text_blocks.is_empty() {
1209 return (
1210 Some(build_error(
1211 id,
1212 INVALID_PARAMS,
1213 "prompt array is empty or contains no text blocks",
1214 )),
1215 vec![],
1216 );
1217 }
1218
1219 if concatenated.is_empty() && !non_text_blocks.is_empty() {
1221 let turn = session.turn;
1223 session.turn += 1;
1224
1225 let user_msg_id = sha256_first_12("");
1226 let user_notif = serde_json::json!({
1227 "jsonrpc": "2.0",
1228 "method": "session/update",
1229 "params": {
1230 "sessionId": sid,
1231 "update": {
1232 "sessionUpdate": "user_message_chunk",
1233 "messageId": user_msg_id,
1234 }
1235 }
1236 });
1237 let agent_notif = serde_json::json!({
1238 "jsonrpc": "2.0",
1239 "method": "session/update",
1240 "params": {
1241 "sessionId": sid,
1242 "update": {
1243 "sessionUpdate": "agent_message_chunk",
1244 "content": {
1245 "type": "text",
1246 "text": format!(
1247 "Unsupported content types in this sprint: {}",
1248 non_text_blocks.join(", ")
1249 ),
1250 }
1251 }
1252 }
1253 });
1254 let end_turn_notif = serde_json::json!({
1255 "jsonrpc": "2.0",
1256 "method": "session/update",
1257 "params": {
1258 "sessionId": sid,
1259 "update": {
1260 "sessionUpdate": "end_turn",
1261 "stopReason": "end_turn",
1262 "turnId": turn.to_string(),
1263 }
1264 }
1265 });
1266 let result = serde_json::json!({
1267 "stopReason": "end_turn",
1268 });
1269 return (
1270 Some(build_success(id, result)),
1271 vec![user_notif, agent_notif, end_turn_notif],
1272 );
1273 }
1274
1275 let turn = session.turn;
1278 let (bridge, mut rx) =
1279 AcpBridge::new(sid.clone(), std::collections::HashMap::new(), turn, false);
1280 session.runtime.replace_event_sink(bridge);
1281
1282 let _ = session.refresh_cancel_token();
1287
1288 let user_msg_id = sha256_first_12(&concatenated);
1290 let user_notif = serde_json::json!({
1291 "jsonrpc": "2.0",
1292 "method": "session/update",
1293 "params": {
1294 "sessionId": sid,
1295 "update": {
1296 "sessionUpdate": "user_message_chunk",
1297 "messageId": user_msg_id,
1298 "content": {
1299 "type": "text",
1300 "text": concatenated,
1301 },
1302 }
1303 }
1304 });
1305
1306 let outcome = match session.runtime.run(concatenated).await {
1308 Ok(o) => o,
1309 Err(e) => {
1310 return (
1311 Some(build_error(
1312 id,
1313 INVALID_PARAMS,
1314 &format!("Agent run failed: {e}"),
1315 )),
1316 vec![],
1317 );
1318 }
1319 };
1320
1321 session.turn += 1;
1323
1324 let mut notifications: Vec<Value> = vec![user_notif];
1326 while let Ok(notif) = rx.try_recv() {
1327 notifications.push(notif);
1328 }
1329
1330 let stop_reason = match outcome.finish_reason {
1332 FinishReason::NoMoreToolCalls => "end_turn",
1333 FinishReason::Cancelled => "cancelled",
1334 FinishReason::BudgetExceeded => "max_turns",
1335 FinishReason::Stuck { .. }
1336 | FinishReason::TranscriptLimit { .. }
1337 | FinishReason::ProviderStop(_)
1338 | FinishReason::PermissionDenialLimit
1339 | FinishReason::WallClockExceeded { .. } => "error",
1340 };
1341
1342 let result = serde_json::json!({
1343 "stopReason": stop_reason,
1344 });
1345
1346 (Some(build_success(id, result)), notifications)
1347}
1348
1349fn handle_session_cancel(
1366 id: &Value,
1367 params: Option<&Value>,
1368 sessions: &mut AcpSessionManager,
1369) -> (Option<Value>, Vec<Value>) {
1370 let sid: Option<String> = params.and_then(|p| {
1372 p.get("sessionId")
1373 .and_then(|v| v.as_str())
1374 .map(|s| s.to_string())
1375 });
1376
1377 if let Some(sid) = sid {
1378 let session = match sessions.get_mut(&sid) {
1380 Some(s) => s,
1381 None => {
1382 return (
1383 Some(build_error(
1384 id,
1385 SESSION_NOT_FOUND,
1386 &format!("Session not found: {sid}"),
1387 )),
1388 vec![],
1389 );
1390 }
1391 };
1392
1393 session.cancel_token.cancel();
1395
1396 (Some(build_success(id, Value::Bool(true))), vec![])
1397 } else {
1398 let active_sid: Option<String> = {
1401 let mut candidates: Vec<(String, u64)> = sessions.all_turns();
1402 candidates.sort_by_key(|(_, turn)| std::cmp::Reverse(*turn));
1403 candidates.into_iter().next().map(|(id, _)| id)
1404 };
1405
1406 match active_sid {
1407 Some(sid) => {
1408 match sessions.get_mut(&sid) {
1409 Some(session) => {
1410 session.cancel_token.cancel();
1411 (Some(build_success(id, Value::Bool(true))), vec![])
1412 }
1413 None => {
1414 (Some(build_success(id, Value::Bool(true))), vec![])
1418 }
1419 }
1420 }
1421 None => (
1422 Some(build_error(
1423 id,
1424 SESSION_NOT_FOUND,
1425 "No active session to cancel",
1426 )),
1427 vec![],
1428 ),
1429 }
1430 }
1431}
1432
1433async fn handle_session_close(
1449 id: &Value,
1450 params: Option<&Value>,
1451 sessions: &mut AcpSessionManager,
1452) -> (Option<Value>, Vec<Value>) {
1453 let sid = match params.and_then(|p| p.get("sessionId").and_then(|v| v.as_str())) {
1454 Some(s) => s.to_string(),
1455 None => {
1456 return (
1457 Some(build_error(
1458 id,
1459 INVALID_PARAMS,
1460 "Missing required field 'sessionId'",
1461 )),
1462 vec![],
1463 );
1464 }
1465 };
1466
1467 if !sessions.contains(&sid) {
1469 return (
1470 Some(build_error(
1471 id,
1472 SESSION_NOT_FOUND,
1473 &format!("Session not found: {sid}"),
1474 )),
1475 vec![],
1476 );
1477 }
1478
1479 if let Some(session) = sessions.get_mut(&sid) {
1481 session.kill_mcp_children().await;
1482 }
1483
1484 sessions.remove(&sid);
1486
1487 tracing::info!(session = %sid, "session/close: session closed");
1488 (Some(build_success(id, Value::Bool(true))), vec![])
1489}
1490
1491pub struct AcpServer;
1497
1498impl AcpServer {
1499 pub async fn run(llm: Option<Arc<dyn ChatProvider>>) {
1508 let stdin = tokio::io::stdin();
1509 let stdout = tokio::io::stdout();
1510 Self::run_stdio(BufReader::new(stdin), stdout, llm).await;
1511 }
1512
1513 async fn run_stdio<R, W>(reader: R, mut writer: W, llm: Option<Arc<dyn ChatProvider>>)
1515 where
1516 R: tokio::io::AsyncBufRead + Unpin,
1517 W: tokio::io::AsyncWrite + Unpin,
1518 {
1519 let sync_table = build_sync_dispatch_table();
1520 let mut lines = BufReader::new(reader).lines();
1521 let mut state = ServerState::Uninitialized;
1522 let mut sessions = AcpSessionManager::new();
1523
1524 loop {
1525 let line =
1527 tokio::time::timeout(std::time::Duration::from_secs(60), lines.next_line()).await;
1528
1529 let line = match line {
1530 Ok(Ok(Some(l))) => l,
1531 Ok(Ok(None)) => {
1532 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1534 continue;
1535 }
1536 Ok(Err(e)) => {
1537 debug!(%e, "ACP stdin read error");
1538 break;
1539 }
1540 Err(_elapsed) => {
1541 tracing::info!("ACP server idle timeout (60s), shutting down");
1543 eprintln!("ServerShutdown");
1544 break;
1545 }
1546 };
1547
1548 Self::process_line(
1549 &line,
1550 &mut writer,
1551 &mut state,
1552 &mut sessions,
1553 llm.as_ref(),
1554 &sync_table,
1555 )
1556 .await;
1557 }
1558
1559 debug!("ACP stdin idle timeout or read error, server shutting down");
1560 }
1561
1562 async fn process_line<W: tokio::io::AsyncWrite + Unpin>(
1565 line: &str,
1566 writer: &mut W,
1567 state: &mut ServerState,
1568 sessions: &mut AcpSessionManager,
1569 llm: Option<&Arc<dyn ChatProvider>>,
1570 sync_table: &HashMap<&'static str, SyncMethodHandler>,
1571 ) {
1572 let line = line.trim();
1573 if line.is_empty() {
1574 return;
1575 }
1576
1577 let value: Value = match serde_json::from_str(line) {
1579 Ok(v) => v,
1580 Err(_) => {
1581 let resp = build_error(&Value::Null, PARSE_ERROR, "Parse error: invalid JSON");
1582 let _ = write_line(writer, &resp).await;
1583 return;
1584 }
1585 };
1586
1587 if let Some(arr) = value.as_array() {
1588 if arr.is_empty() {
1590 let resp = build_error(&Value::Null, INVALID_REQUEST, "Empty batch is not allowed");
1591 let _ = write_line(writer, &resp).await;
1592 return;
1593 }
1594
1595 let mut responses: Vec<Value> = Vec::new();
1596 let mut batch_notifications: Vec<Value> = Vec::new();
1597 for item in arr {
1598 let (resp, notifs) =
1599 Self::dispatch_one(item, state, sessions, llm, sync_table).await;
1600 if let Some(resp) = resp {
1601 responses.push(resp);
1602 }
1603 batch_notifications.extend(notifs);
1604 }
1605
1606 if !responses.is_empty() {
1607 let json = serde_json::to_string(&responses).unwrap_or_default();
1608 let _ = writer.write_all(json.as_bytes()).await;
1609 let _ = writer.write_all(b"\n").await;
1610 let _ = writer.flush().await;
1611 }
1612 for notif in &batch_notifications {
1613 let _ = write_line(writer, notif).await;
1614 }
1615 } else {
1616 let (resp, notifications) =
1618 Self::dispatch_one(&value, state, sessions, llm, sync_table).await;
1619 if let Some(resp) = resp {
1620 let _ = write_line(writer, &resp).await;
1621 }
1622 for notif in ¬ifications {
1623 let _ = write_line(writer, notif).await;
1624 }
1625 }
1626 }
1627
1628 async fn dispatch_one(
1631 item: &Value,
1632 state: &mut ServerState,
1633 sessions: &mut AcpSessionManager,
1634 llm: Option<&Arc<dyn ChatProvider>>,
1635 sync_table: &HashMap<&'static str, SyncMethodHandler>,
1636 ) -> (Option<Value>, Vec<Value>) {
1637 let env: JsonRpcEnvelope = match serde_json::from_value(item.clone()) {
1639 Ok(e) => e,
1640 Err(_) => {
1641 let id = item.get("id").cloned().unwrap_or(Value::Null);
1642 return (
1643 Some(build_error(
1644 &id,
1645 PARSE_ERROR,
1646 "Parse error: invalid JSON-RPC message",
1647 )),
1648 vec![],
1649 );
1650 }
1651 };
1652
1653 match &env.jsonrpc {
1655 None => {
1656 let id = env.id.clone().unwrap_or(Value::Null);
1657 return (
1658 Some(build_error(
1659 &id,
1660 INVALID_REQUEST,
1661 "Invalid Request: missing 'jsonrpc' field",
1662 )),
1663 vec![],
1664 );
1665 }
1666 Some(v) if v != "2.0" => {
1667 let id = env.id.clone().unwrap_or(Value::Null);
1668 return (
1669 Some(build_error(
1670 &id,
1671 INVALID_REQUEST,
1672 &format!("Invalid Request: expected jsonrpc '2.0', got '{v}'"),
1673 )),
1674 vec![],
1675 );
1676 }
1677 _ => {}
1678 }
1679
1680 let method = match &env.method {
1682 Some(m) => m.as_str(),
1683 None => {
1684 let id = env.id.clone().unwrap_or(Value::Null);
1685 return (
1686 Some(build_error(
1687 &id,
1688 INVALID_REQUEST,
1689 "Invalid Request: missing required field 'method'",
1690 )),
1691 vec![],
1692 );
1693 }
1694 };
1695
1696 let id = match &env.id {
1698 Some(id) => id.clone(),
1699 None => return (None, vec![]),
1700 };
1701
1702 if let Some(handler) = sync_table.get(method) {
1704 let resp = handler(&id, env.params.as_ref());
1705
1706 if resp.get("result").is_some() && method == "initialize" {
1710 match *state {
1711 ServerState::Uninitialized => {
1712 *state = ServerState::Initialized;
1713
1714 if let Some(params) = env.params.as_ref() {
1716 if let Some(caps_val) = params.get("capabilities") {
1717 if let Ok(caps) =
1718 serde_json::from_value::<ClientCapabilities>(caps_val.clone())
1719 {
1720 sessions.set_client_capabilities(Some(caps));
1721 tracing::info!(
1722 "ACP: stored client capabilities from initialize"
1723 );
1724 }
1725 }
1726 }
1727
1728 let notif = serde_json::json!({
1729 "jsonrpc": "2.0",
1730 "method": "initialized",
1731 });
1732 return (Some(resp), vec![notif]);
1733 }
1734 ServerState::Initialized => {
1735 return (
1737 Some(build_error(
1738 &id,
1739 SERVER_NOT_INITIALIZED,
1740 "Server already initialized",
1741 )),
1742 vec![],
1743 );
1744 }
1745 }
1746 }
1747
1748 return (Some(resp), vec![]);
1749 }
1750
1751 dispatch_async(method, &id, env.params.as_ref(), state, sessions, llm).await
1753 }
1754
1755 pub async fn run_io<R, W>(reader: R, mut writer: W, llm: Option<Arc<dyn ChatProvider>>)
1757 where
1758 R: tokio::io::AsyncBufRead + Unpin,
1759 W: tokio::io::AsyncWrite + Unpin,
1760 {
1761 let sync_table = build_sync_dispatch_table();
1762 let mut lines = BufReader::new(reader).lines();
1763 let mut state = ServerState::Uninitialized;
1764 let mut sessions = AcpSessionManager::new();
1765
1766 while let Some(line) = match lines.next_line().await {
1767 Ok(l) => l,
1768 Err(e) => {
1769 debug!(%e, "ACP stdin read error");
1770 None
1771 }
1772 } {
1773 Self::process_line(
1774 &line,
1775 &mut writer,
1776 &mut state,
1777 &mut sessions,
1778 llm.as_ref(),
1779 &sync_table,
1780 )
1781 .await;
1782 }
1783
1784 debug!("ACP stdin closed, server shutting down");
1785 }
1786}
1787
1788#[cfg(test)]
1793mod tests {
1794 use super::*;
1795 use crate::llm::MockProvider;
1796
1797 fn json_path(path: &std::path::Path) -> String {
1802 serde_json::to_string(&path.to_string_lossy().as_ref()).expect("path is valid UTF-8")
1803 }
1804
1805 fn mock_llm() -> Arc<dyn ChatProvider> {
1807 Arc::new(MockProvider::new(vec![crate::llm::Completion {
1808 content: "Hello from agent".into(),
1809 ..Default::default()
1810 }]))
1811 }
1812
1813 fn mock_llm_multi(completions: Vec<crate::llm::Completion>) -> Arc<dyn ChatProvider> {
1815 Arc::new(MockProvider::new(completions))
1816 }
1817
1818 async fn run_server(input: &str) -> Vec<String> {
1821 run_server_with_llm(input, Some(mock_llm())).await
1822 }
1823
1824 #[allow(dead_code)]
1826 async fn run_server_no_llm(input: &str) -> Vec<String> {
1827 run_server_with_llm(input, None).await
1828 }
1829
1830 async fn run_server_with_llm(input: &str, llm: Option<Arc<dyn ChatProvider>>) -> Vec<String> {
1831 let reader = std::io::Cursor::new(input.as_bytes().to_owned());
1832 let mut output = Vec::<u8>::new();
1833 AcpServer::run_io(reader, &mut output, llm).await;
1834 let text = String::from_utf8(output).unwrap();
1835 text.lines().map(|s| s.to_string()).collect()
1836 }
1837
1838 fn parse_line(lines: &[String], idx: usize) -> Value {
1840 serde_json::from_str(&lines[idx]).expect("valid JSON")
1841 }
1842
1843 fn find_notifications(lines: &[String]) -> Vec<Value> {
1845 lines
1846 .iter()
1847 .filter_map(|l| {
1848 let v: Value = serde_json::from_str(l).ok()?;
1849 if v.get("method").and_then(|m| m.as_str()) == Some("session/update") {
1850 Some(v)
1851 } else {
1852 None
1853 }
1854 })
1855 .collect()
1856 }
1857
1858 #[tokio::test]
1863 async fn initialize_returns_success() {
1864 let lines = run_server(
1865 r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1}}"#,
1866 )
1867 .await;
1868 assert_eq!(
1869 lines.len(),
1870 2,
1871 "expected response + initialized notification"
1872 );
1873 let v = parse_line(&lines, 0);
1874 assert_eq!(v["jsonrpc"], "2.0");
1875 assert_eq!(v["id"], 1);
1876 assert!(v["result"].is_object());
1877 let notif = parse_line(&lines, 1);
1878 assert!(notif["id"].is_null() || notif.get("id").is_none());
1879 assert_eq!(notif["method"], "initialized");
1880 }
1881
1882 #[tokio::test]
1887 async fn initialize_has_protocol_version_and_agent_info() {
1888 let lines = run_server(
1889 r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1}}"#,
1890 )
1891 .await;
1892 let v = parse_line(&lines, 0);
1893 let result = &v["result"];
1894 assert_eq!(result["protocolVersion"], 1);
1895 assert_eq!(result["agentInfo"]["name"], "recursive");
1896 assert!(result["agentInfo"]["version"].is_string());
1897 }
1898
1899 #[tokio::test]
1901 async fn agent_capabilities_has_flat_keys() {
1902 let lines = run_server(
1903 r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1}}"#,
1904 )
1905 .await;
1906 let v = parse_line(&lines, 0);
1907 let caps = &v["result"]["agentCapabilities"];
1908
1909 assert_eq!(caps["terminalCapabilities"], false);
1911 let truthy_keys = [
1913 "promptCapabilities",
1914 "toolCallNotifications",
1915 "loadSession",
1916 "resume",
1917 "fs.readTextFile",
1918 "fs.writeTextFile",
1919 "mcpCapabilities",
1920 ];
1921 for key in &truthy_keys {
1922 assert!(
1923 !caps[key].is_null() && caps[key] != serde_json::Value::Bool(false),
1924 "agentCapabilities key '{key}' must be truthy, got: {caps}"
1925 );
1926 }
1927
1928 assert!(caps.as_object().map_or(0, |o| o.len()) >= 5);
1930 }
1931
1932 #[tokio::test]
1937 async fn malformed_json_returns_parse_error() {
1938 let lines = run_server("not json").await;
1939 assert_eq!(lines.len(), 1);
1940 let v = parse_line(&lines, 0);
1941 assert_eq!(v["jsonrpc"], "2.0");
1942 assert_eq!(v["id"], Value::Null);
1943 assert_eq!(v["error"]["code"], PARSE_ERROR);
1944 }
1945
1946 #[tokio::test]
1948 async fn unclosed_brace_returns_parse_error() {
1949 let lines = run_server("{bad json").await;
1950 assert_eq!(lines.len(), 1);
1951 let v = parse_line(&lines, 0);
1952 assert_eq!(v["jsonrpc"], "2.0");
1953 assert_eq!(v["id"], Value::Null);
1954 assert_eq!(v["error"]["code"], PARSE_ERROR);
1955 let msg = v["error"]["message"].as_str().unwrap();
1956 assert!(!msg.is_empty(), "error message must not be empty");
1957 }
1958
1959 #[tokio::test]
1961 async fn parse_error_then_valid_request_process_survives() {
1962 let input = concat!(
1963 "{bad json\n",
1964 r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1}}"#,
1965 );
1966 let lines = run_server(input).await;
1967 assert!(
1968 lines.len() >= 2,
1969 "parse error + init response + initialized"
1970 );
1971 let v0 = parse_line(&lines, 0);
1972 assert_eq!(v0["error"]["code"], PARSE_ERROR);
1973 assert_eq!(v0["id"], Value::Null);
1974 let v1 = parse_line(&lines, 1);
1975 assert!(v1["result"].is_object());
1976 assert_eq!(v1["result"]["protocolVersion"], 1);
1977 }
1978
1979 #[tokio::test]
1980 async fn parse_error_then_valid_initialize() {
1981 let input = concat!(
1982 "garbage\n",
1983 r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1}}"#,
1984 );
1985 let lines = run_server(input).await;
1986 assert!(
1987 lines.len() >= 2,
1988 "parse error + init response + initialized"
1989 );
1990 let v0 = parse_line(&lines, 0);
1991 assert_eq!(v0["error"]["code"], PARSE_ERROR);
1992 assert_eq!(v0["id"], Value::Null);
1993 let v1 = parse_line(&lines, 1);
1994 assert_eq!(v1["result"]["protocolVersion"], 1);
1995 }
1996
1997 #[tokio::test]
2002 async fn json_without_jsonrpc_returns_invalid_request() {
2003 let lines = run_server(r#"{"id":1}"#).await;
2004 assert_eq!(lines.len(), 1);
2005 let v = parse_line(&lines, 0);
2006 assert_eq!(v["id"], 1);
2007 assert_eq!(v["error"]["code"], INVALID_REQUEST);
2008 let msg = v["error"]["message"].as_str().unwrap().to_lowercase();
2009 assert!(msg.contains("invalid request"), "msg: {msg}");
2010 }
2011
2012 #[tokio::test]
2013 async fn missing_method_returns_invalid_request() {
2014 let lines = run_server(r#"{"jsonrpc":"2.0","id":1}"#).await;
2015 assert_eq!(lines.len(), 1);
2016 let v = parse_line(&lines, 0);
2017 assert_eq!(v["error"]["code"], INVALID_REQUEST);
2018 }
2019
2020 #[tokio::test]
2028 async fn before_init_unknown_method_returns_method_not_found() {
2029 let input = r#"{"jsonrpc":"2.0","id":99,"method":"nonexistent_method","params":{}}"#;
2030 let lines = run_server(input).await;
2031 assert_eq!(lines.len(), 1);
2032 let v = parse_line(&lines, 0);
2033 assert_eq!(v["id"], 99);
2034 assert_eq!(v["error"]["code"], METHOD_NOT_FOUND);
2035 let msg = v["error"]["message"].as_str().unwrap();
2036 assert!(!msg.is_empty(), "error message must not be empty");
2037 }
2038
2039 #[tokio::test]
2041 async fn unknown_method_then_initialize_still_works() {
2042 let input = concat!(
2043 r#"{"jsonrpc":"2.0","id":99,"method":"nonexistent_method","params":{}}"#,
2044 "\n",
2045 r#"{"jsonrpc":"2.0","id":100,"method":"initialize","params":{"protocolVersion":1}}"#,
2046 );
2047 let lines = run_server(input).await;
2048 assert!(lines.len() >= 2, "error + response + initialized");
2049 let v0 = parse_line(&lines, 0);
2050 assert_eq!(v0["id"], 99);
2051 assert_eq!(v0["error"]["code"], METHOD_NOT_FOUND);
2052 let v1 = parse_line(&lines, 1);
2053 assert_eq!(v1["id"], 100);
2054 assert!(v1["result"].is_object());
2055 assert_eq!(v1["result"]["protocolVersion"], 1);
2056 }
2057
2058 #[tokio::test]
2059 async fn after_init_unknown_method_is_not_found() {
2060 let input = concat!(
2061 r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1}}"#,
2062 "\n",
2063 r#"{"jsonrpc":"2.0","id":2,"method":"nonexistent","params":{}}"#,
2064 );
2065 let lines = run_server(input).await;
2066 assert_eq!(lines.len(), 3, "response + initialized + error");
2067 let v0 = parse_line(&lines, 0);
2068 assert!(v0["result"].is_object());
2069 let v1 = parse_line(&lines, 1);
2070 assert_eq!(v1["method"], "initialized");
2071 let v2 = parse_line(&lines, 2);
2072 assert_eq!(v2["error"]["code"], METHOD_NOT_FOUND);
2073 }
2074
2075 #[tokio::test]
2080 async fn notification_produces_no_response() {
2081 let input = concat!(
2082 r#"{"jsonrpc":"2.0","method":"someNotification","params":{}}"#,
2083 "\n",
2084 r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1}}"#,
2085 );
2086 let lines = run_server(input).await;
2087 assert_eq!(
2088 lines.len(),
2089 2,
2090 "only initialize response + initialized notification"
2091 );
2092 let v = parse_line(&lines, 0);
2093 assert_eq!(v["id"], 1);
2094 assert!(v["result"].is_object());
2095 }
2096
2097 #[tokio::test]
2098 async fn notification_alone_produces_nothing() {
2099 let lines =
2100 run_server(r#"{"jsonrpc":"2.0","method":"someNotification","params":{}}"#).await;
2101 assert!(lines.is_empty());
2102 }
2103
2104 #[tokio::test]
2109 async fn initialized_notification_after_init_response() {
2110 let input = concat!(
2111 r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1}}"#,
2112 "\n",
2113 r#"{"jsonrpc":"2.0","id":2,"method":"ping","params":{}}"#,
2114 );
2115 let lines = run_server(input).await;
2116 assert_eq!(lines.len(), 3, "init response + initialized + error");
2117 let v0 = parse_line(&lines, 0);
2118 assert_eq!(v0["id"], 1);
2119 assert!(v0["result"].is_object());
2120 let v1 = parse_line(&lines, 1);
2121 assert!(
2122 v1["id"].is_null() || v1.get("id").is_none(),
2123 "notification has no id"
2124 );
2125 assert_eq!(v1["method"], "initialized");
2126 let v2 = parse_line(&lines, 2);
2127 assert_eq!(v2["id"], 2);
2128 }
2129
2130 #[tokio::test]
2135 async fn multi_request_fifo_ordering() {
2136 let input = concat!(
2137 r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1}}"#,
2138 "\n",
2139 r#"{"jsonrpc":"2.0","id":10,"method":"a","params":{}}"#,
2140 "\n",
2141 r#"{"jsonrpc":"2.0","id":20,"method":"b","params":{}}"#,
2142 "\n",
2143 r#"{"jsonrpc":"2.0","id":30,"method":"c","params":{}}"#,
2144 );
2145 let lines = run_server(input).await;
2146 assert_eq!(lines.len(), 5, "init + initialized + 3 errors");
2147 assert_eq!(parse_line(&lines, 0)["id"], 1);
2148 assert_eq!(parse_line(&lines, 1)["method"], "initialized");
2149 assert_eq!(parse_line(&lines, 2)["id"], 10);
2150 assert_eq!(parse_line(&lines, 2)["error"]["code"], METHOD_NOT_FOUND);
2151 assert_eq!(parse_line(&lines, 3)["id"], 20);
2152 assert_eq!(parse_line(&lines, 3)["error"]["code"], METHOD_NOT_FOUND);
2153 assert_eq!(parse_line(&lines, 4)["id"], 30);
2154 assert_eq!(parse_line(&lines, 4)["error"]["code"], METHOD_NOT_FOUND);
2155 }
2156
2157 #[tokio::test]
2161 async fn three_initialize_requests_fifo() {
2162 let input = concat!(
2163 r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1}}"#,
2164 "\n",
2165 r#"{"jsonrpc":"2.0","id":2,"method":"initialize","params":{"protocolVersion":1}}"#,
2166 "\n",
2167 r#"{"jsonrpc":"2.0","id":3,"method":"initialize","params":{"protocolVersion":1}}"#,
2168 );
2169 let lines = run_server(input).await;
2170 assert_eq!(lines.len(), 4, "init response + initialized + 2 errors");
2172
2173 let v0 = parse_line(&lines, 0);
2175 assert_eq!(v0["jsonrpc"], "2.0");
2176 assert_eq!(v0["id"], 1);
2177 assert!(v0["result"].is_object());
2178
2179 let v1 = parse_line(&lines, 1);
2181 assert!(v1["id"].is_null() || v1.get("id").is_none());
2182 assert_eq!(v1["method"], "initialized");
2183
2184 let v2 = parse_line(&lines, 2);
2186 assert_eq!(v2["jsonrpc"], "2.0");
2187 assert_eq!(v2["id"], 2);
2188 assert_eq!(v2["error"]["code"], SERVER_NOT_INITIALIZED);
2189
2190 let v3 = parse_line(&lines, 3);
2192 assert_eq!(v3["jsonrpc"], "2.0");
2193 assert_eq!(v3["id"], 3);
2194 assert_eq!(v3["error"]["code"], SERVER_NOT_INITIALIZED);
2195 }
2196
2197 #[tokio::test]
2199 async fn every_stdout_line_is_valid_jsonrpc() {
2200 let input = concat!(
2201 r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1}}"#,
2202 "\n",
2203 r#"{"jsonrpc":"2.0","id":2,"method":"nonexistent_method","params":{}}"#,
2204 "\n",
2205 r#"{"jsonrpc":"2.0","method":"aNotification","params":{}}"#,
2206 "\n",
2207 r#"garbage"#,
2208 );
2209 let lines = run_server(input).await;
2210 for line in &lines {
2211 let v: Value =
2212 serde_json::from_str(line).unwrap_or_else(|_| panic!("invalid JSON line: {line}"));
2213 assert_eq!(
2214 v["jsonrpc"], "2.0",
2215 "every stdout line must have jsonrpc='2.0', got: {line}"
2216 );
2217 }
2218 }
2219
2220 #[tokio::test]
2225 async fn before_init_other_methods_return_server_not_initialized() {
2226 let input = r#"{"jsonrpc":"2.0","id":2,"method":"session/new","params":{}}"#;
2227 let lines = run_server(input).await;
2228 assert_eq!(lines.len(), 1);
2229 let v = parse_line(&lines, 0);
2230 assert_eq!(v["error"]["code"], SERVER_NOT_INITIALIZED);
2231 }
2232
2233 #[tokio::test]
2234 async fn duplicate_initialize_returns_error() {
2235 let input = concat!(
2236 r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1}}"#,
2237 "\n",
2238 r#"{"jsonrpc":"2.0","id":2,"method":"initialize","params":{"protocolVersion":1}}"#,
2239 );
2240 let lines = run_server(input).await;
2241 assert_eq!(lines.len(), 3, "response + initialized + error");
2242 let v0 = parse_line(&lines, 0);
2243 assert!(v0["result"].is_object());
2244 let v1 = parse_line(&lines, 1);
2245 assert_eq!(v1["method"], "initialized");
2246 let v2 = parse_line(&lines, 2);
2247 assert!(v2["error"].is_object());
2248 assert_eq!(v2["error"]["code"], SERVER_NOT_INITIALIZED);
2249 }
2250
2251 #[tokio::test]
2256 async fn missing_protocol_version_returns_invalid_params() {
2257 let input = r#"{"jsonrpc":"2.0","id":3,"method":"initialize","params":{}}"#;
2258 let lines = run_server(input).await;
2259 assert_eq!(lines.len(), 1);
2260 let v = parse_line(&lines, 0);
2261 assert_eq!(v["error"]["code"], INVALID_PARAMS);
2262 }
2263
2264 #[tokio::test]
2265 async fn string_protocol_version_returns_invalid_params() {
2266 let input =
2267 r#"{"jsonrpc":"2.0","id":4,"method":"initialize","params":{"protocolVersion":"1"}}"#;
2268 let lines = run_server(input).await;
2269 assert_eq!(lines.len(), 1);
2270 let v = parse_line(&lines, 0);
2271 assert_eq!(v["error"]["code"], INVALID_PARAMS);
2272 }
2273
2274 #[tokio::test]
2275 async fn unsupported_protocol_version_returns_invalid_params() {
2276 let input =
2277 r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":99}}"#;
2278 let lines = run_server(input).await;
2279 assert_eq!(lines.len(), 1);
2280 let v = parse_line(&lines, 0);
2281 assert_eq!(v["error"]["code"], INVALID_PARAMS);
2282 }
2283
2284 #[tokio::test]
2289 async fn initialize_response_has_flat_capabilities() {
2290 let lines = run_server(
2291 r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1}}"#,
2292 )
2293 .await;
2294 let v = parse_line(&lines, 0);
2295 let result = &v["result"];
2296 assert_eq!(result["protocolVersion"], 1);
2297 assert_eq!(result["agentInfo"]["name"], "recursive");
2298 assert!(result["agentInfo"]["version"].is_string());
2299
2300 let caps = &result["agentCapabilities"];
2301 assert!(caps["promptCapabilities"].is_object());
2303 assert!(caps["toolCallNotifications"].is_object());
2304 assert!(caps["loadSession"].is_object());
2305 assert!(caps["resume"].is_object());
2306 assert!(caps["fs.readTextFile"].is_object());
2307 assert!(caps["fs.writeTextFile"].is_object());
2308 assert!(caps["mcpCapabilities"].is_object());
2309 assert_eq!(caps["terminalCapabilities"], false);
2311 }
2312
2313 #[tokio::test]
2318 async fn session_cancel_valid_session_returns_result_true() {
2319 let tmp = tempfile::TempDir::new().unwrap();
2320 let cwd = json_path(tmp.path());
2321 let input = format!(
2322 r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":1}}}}
2323{{"jsonrpc":"2.0","id":2,"method":"session/new","params":{{"cwd":{}}}}}
2324{{"jsonrpc":"2.0","id":3,"method":"session/cancel","params":{{"sessionId":"acp-sess-1"}}}}"#,
2325 cwd
2326 );
2327 let lines = run_server(&input).await;
2328 assert!(lines.len() >= 4, "expected ≥4 lines, got {}", lines.len());
2330 let v = parse_line(&lines, 3);
2331 assert_eq!(v["result"], true, "expected result:true, got: {v}");
2333 }
2334
2335 #[tokio::test]
2336 async fn session_cancel_nonexistent_session_returns_error() {
2337 let input = concat!(
2338 r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1}}"#,
2339 "\n",
2340 r#"{"jsonrpc":"2.0","id":2,"method":"session/cancel","params":{"sessionId":"nonexistent"}}"#,
2341 );
2342 let lines = run_server(input).await;
2343 assert_eq!(lines.len(), 3, "init + initialized + error");
2344 let v = parse_line(&lines, 2);
2345 assert_eq!(v["error"]["code"], SESSION_NOT_FOUND);
2346 let msg = v["error"]["message"].as_str().unwrap();
2347 assert!(msg.contains("not found"), "msg should say not found: {msg}");
2348 }
2349
2350 #[tokio::test]
2351 async fn session_cancel_missing_session_id_returns_400() {
2352 let input = concat!(
2353 r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1}}"#,
2354 "\n",
2355 r#"{"jsonrpc":"2.0","id":2,"method":"session/cancel","params":{}}"#,
2356 );
2357 let lines = run_server(input).await;
2358 assert_eq!(lines.len(), 3, "init + initialized + error");
2359 let v = parse_line(&lines, 2);
2360 assert_eq!(v["error"]["code"], SESSION_NOT_FOUND);
2361 }
2362
2363 #[tokio::test]
2364 async fn session_cancel_wrong_type_session_id_returns_error() {
2365 let input = concat!(
2366 r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1}}"#,
2367 "\n",
2368 r#"{"jsonrpc":"2.0","id":2,"method":"session/cancel","params":{"sessionId":123}}"#,
2369 );
2370 let lines = run_server(input).await;
2371 assert_eq!(lines.len(), 3, "init + initialized + error");
2372 let v = parse_line(&lines, 2);
2373 assert_eq!(v["error"]["code"], SESSION_NOT_FOUND);
2374 }
2375
2376 #[tokio::test]
2381 async fn cancel_token_fresh_after_each_turn() {
2382 let tmp = tempfile::TempDir::new().unwrap();
2383 let cwd = json_path(tmp.path());
2384
2385 let multi_llm = mock_llm_multi(vec![
2386 crate::llm::Completion {
2387 content: "reply 1".into(),
2388 ..Default::default()
2389 },
2390 crate::llm::Completion {
2391 content: "reply 2".into(),
2392 ..Default::default()
2393 },
2394 ]);
2395
2396 let input = format!(
2400 r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":1}}}}
2401{{"jsonrpc":"2.0","id":2,"method":"session/new","params":{{"cwd":{}}}}}
2402{{"jsonrpc":"2.0","id":3,"method":"session/cancel","params":{{"sessionId":"acp-sess-1"}}}}
2403{{"jsonrpc":"2.0","id":4,"method":"session/prompt","params":{{"sessionId":"acp-sess-1","prompt":[{{"type":"text","text":"hi"}}]}}}}"#,
2404 cwd
2405 );
2406
2407 let mut sessions = AcpSessionManager::new();
2409 use tokio::io::AsyncBufReadExt;
2410 let sync_table = build_sync_dispatch_table();
2411 let reader = std::io::Cursor::new(input.as_bytes().to_owned());
2412 let mut lines = BufReader::new(reader).lines();
2413 let mut state = ServerState::Uninitialized;
2414 let mut output = Vec::<u8>::new();
2415
2416 while let Some(line) = lines.next_line().await.unwrap() {
2417 let line = line.trim().to_string();
2418 if line.is_empty() {
2419 continue;
2420 }
2421 let value: Value = match serde_json::from_str(&line) {
2422 Ok(v) => v,
2423 Err(_) => continue,
2424 };
2425 let (resp, notifs) = AcpServer::dispatch_one(
2426 &value,
2427 &mut state,
2428 &mut sessions,
2429 Some(&multi_llm),
2430 &sync_table,
2431 )
2432 .await;
2433 if let Some(resp) = resp {
2434 let json = serde_json::to_string(&resp).unwrap();
2435 output.extend_from_slice(json.as_bytes());
2436 output.extend_from_slice(b"\n");
2437 }
2438 for notif in ¬ifs {
2439 let json = serde_json::to_string(notif).unwrap();
2440 output.extend_from_slice(json.as_bytes());
2441 output.extend_from_slice(b"\n");
2442 }
2443 }
2444
2445 let session = sessions.get_mut("acp-sess-1").unwrap();
2448 assert!(
2449 !session.cancel_token.is_cancelled(),
2450 "token after turn should be fresh (not cancelled)"
2451 );
2452 }
2453
2454 #[tokio::test]
2459 async fn batch_returns_array_of_responses() {
2460 let input = "[{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":1}},{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"initialize\",\"params\":{\"protocolVersion\":1}}]";
2461 let lines = run_server(input).await;
2462 assert_eq!(lines.len(), 2, "batch array + initialized notification");
2463 let v: Value = serde_json::from_str(&lines[0]).unwrap();
2464 assert!(v.is_array());
2465 let arr = v.as_array().unwrap();
2466 assert_eq!(arr.len(), 2);
2467 }
2468
2469 #[tokio::test]
2470 async fn batch_before_init_mixed_methods() {
2471 let input = "[{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"session/new\",\"params\":{}},{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":1}}]";
2472 let lines = run_server(input).await;
2473 assert_eq!(lines.len(), 2, "batch array + initialized notification");
2474 let v: Value = serde_json::from_str(&lines[0]).unwrap();
2475 let arr = v.as_array().unwrap();
2476 assert_eq!(arr.len(), 2);
2477 assert_eq!(arr[0]["error"]["code"], SERVER_NOT_INITIALIZED);
2478 assert!(arr[1]["result"].is_object());
2479 }
2480
2481 #[tokio::test]
2482 async fn empty_input_produces_no_output() {
2483 let lines = run_server("").await;
2484 assert!(lines.is_empty());
2485 }
2486
2487 #[tokio::test]
2488 async fn blank_line_skipped() {
2489 let input = "\n\n{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":1}}\n\n";
2490 let lines = run_server(input).await;
2491 assert_eq!(lines.len(), 2, "response + initialized notification");
2492 }
2493
2494 #[tokio::test]
2495 async fn batch_with_only_notifications_produces_no_output() {
2496 let input = r#"[{"jsonrpc":"2.0","method":"note1","params":{}},{"jsonrpc":"2.0","method":"note2","params":{}}]"#;
2497 let lines = run_server(input).await;
2498 assert!(lines.is_empty());
2499 }
2500
2501 #[tokio::test]
2502 async fn batch_empty_array_is_error() {
2503 let lines = run_server("[]").await;
2504 assert_eq!(lines.len(), 1);
2505 let v = parse_line(&lines, 0);
2506 assert_eq!(v["error"]["code"], INVALID_REQUEST);
2507 }
2508
2509 #[tokio::test]
2510 async fn batch_malformed_json_is_parse_error() {
2511 let lines = run_server("[not json]").await;
2512 assert_eq!(lines.len(), 1);
2513 let v = parse_line(&lines, 0);
2514 assert_eq!(v["error"]["code"], PARSE_ERROR);
2515 }
2516
2517 #[tokio::test]
2518 async fn batch_with_mixed_notifications_and_requests() {
2519 let input = r#"[{"jsonrpc":"2.0","method":"note","params":{}},{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1}}]"#;
2520 let lines = run_server(input).await;
2521 assert_eq!(lines.len(), 2, "batch array + initialized notification");
2522 let v: Value = serde_json::from_str(&lines[0]).unwrap();
2523 let arr = v.as_array().unwrap();
2524 assert_eq!(arr.len(), 1);
2525 assert_eq!(arr[0]["id"], 1);
2526 }
2527
2528 #[tokio::test]
2533 async fn initialize_with_string_id() {
2534 let lines = run_server(
2535 r#"{"jsonrpc":"2.0","id":"abc-123","method":"initialize","params":{"protocolVersion":1}}"#,
2536 )
2537 .await;
2538 assert_eq!(lines.len(), 2, "response + initialized notification");
2539 let v = parse_line(&lines, 0);
2540 assert_eq!(v["id"], "abc-123");
2541 assert!(v["result"].is_object());
2542 }
2543
2544 #[tokio::test]
2545 async fn invalid_jsonrpc_version_is_invalid_request() {
2546 let input =
2547 r#"{"jsonrpc":"1.0","id":1,"method":"initialize","params":{"protocolVersion":1}}"#;
2548 let lines = run_server(input).await;
2549 assert_eq!(lines.len(), 1);
2550 let v = parse_line(&lines, 0);
2551 assert_eq!(v["error"]["code"], INVALID_REQUEST);
2552 }
2553
2554 #[tokio::test]
2555 async fn notifications_initialized_is_silent() {
2556 let input = concat!(
2557 r#"{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}"#,
2558 "\n",
2559 r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1}}"#,
2560 );
2561 let lines = run_server(input).await;
2562 assert_eq!(lines.len(), 2, "response + initialized notification");
2563 let v = parse_line(&lines, 0);
2564 assert_eq!(v["id"], 1);
2565 }
2566
2567 #[tokio::test]
2568 async fn large_payload_does_not_crash() {
2569 let big = "x".repeat(10_000);
2570 let input = format!(
2571 r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":1,"big":"{}"}}}}"#,
2572 big
2573 );
2574 let lines = run_server(&input).await;
2575 assert_eq!(lines.len(), 2, "response + initialized notification");
2576 let v = parse_line(&lines, 0);
2577 assert!(v["result"].is_object());
2578 }
2579
2580 #[tokio::test]
2581 async fn jsonrpc_two_point_zero_is_valid() {
2582 let lines = run_server(
2583 r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1}}"#,
2584 )
2585 .await;
2586 assert_eq!(lines.len(), 2);
2587 }
2588
2589 #[tokio::test]
2600 async fn idle_timeout_shuts_down_server_after_60s() {
2601 tokio::time::pause();
2602
2603 let (reader, _writer) = tokio::io::duplex(64);
2607 let reader = tokio::io::BufReader::new(reader);
2608 let mut output = Vec::<u8>::new();
2609
2610 let handle = tokio::spawn(async move {
2611 AcpServer::run_stdio(reader, &mut output, None).await;
2612 output
2613 });
2614
2615 tokio::task::yield_now().await;
2617
2618 tokio::time::advance(std::time::Duration::from_secs(61)).await;
2620
2621 let result = tokio::time::timeout(std::time::Duration::from_millis(500), handle).await;
2623 match result {
2624 Ok(Ok(output)) => {
2625 assert!(
2626 output.is_empty(),
2627 "no output should be produced on idle timeout"
2628 );
2629 }
2630 Ok(Err(join_err)) => {
2631 panic!("server task panicked: {join_err}");
2632 }
2633 Err(_) => {
2634 panic!("server did not shut down within 500ms after idle timeout");
2635 }
2636 }
2637 }
2638
2639 #[tokio::test]
2642 async fn idle_timeout_does_not_fire_before_60s() {
2643 tokio::time::pause();
2644
2645 let (reader, _writer) = tokio::io::duplex(64);
2646 let reader = tokio::io::BufReader::new(reader);
2647 let mut output = Vec::<u8>::new();
2648
2649 let handle = tokio::spawn(async move {
2650 AcpServer::run_stdio(reader, &mut output, None).await;
2651 output
2652 });
2653
2654 tokio::task::yield_now().await;
2655
2656 tokio::time::advance(std::time::Duration::from_secs(30)).await;
2658
2659 assert!(!handle.is_finished(), "server should still be alive at 30s");
2661
2662 tokio::time::advance(std::time::Duration::from_secs(31)).await;
2664
2665 let result = tokio::time::timeout(std::time::Duration::from_millis(500), handle).await;
2666 assert!(
2667 result.is_ok(),
2668 "server should shut down after 61s total idle time"
2669 );
2670 }
2671
2672 #[tokio::test]
2677 async fn session_new_returns_session_id() {
2678 let tmp = tempfile::TempDir::new().unwrap();
2679 let cwd = json_path(tmp.path());
2680 let input = format!(
2681 r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":1}}}}
2682{{"jsonrpc":"2.0","id":2,"method":"session/new","params":{{"cwd":{}}}}}"#,
2683 cwd
2684 );
2685 let lines = run_server(&input).await;
2686 assert!(
2687 lines.len() >= 3,
2688 "init + initialized + session/new response"
2689 );
2690 let v = parse_line(&lines, 2);
2691 assert!(v["result"].is_object(), "expected success, got: {v}");
2692 assert!(v["result"]["sessionId"].is_string());
2693 assert!(!v["result"]["sessionId"].as_str().unwrap().is_empty());
2694 }
2695
2696 #[tokio::test]
2697 async fn session_new_missing_cwd_returns_error() {
2698 let input = concat!(
2699 r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1}}"#,
2700 "\n",
2701 r#"{"jsonrpc":"2.0","id":2,"method":"session/new","params":{}}"#,
2702 );
2703 let lines = run_server(input).await;
2704 assert_eq!(lines.len(), 3, "init + initialized + error");
2705 let v = parse_line(&lines, 2);
2706 assert_eq!(v["error"]["code"], INVALID_PARAMS);
2707 }
2708
2709 #[tokio::test]
2710 async fn session_new_nonexistent_cwd_returns_error() {
2711 let input = concat!(
2712 r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1}}"#,
2713 "\n",
2714 r#"{"jsonrpc":"2.0","id":2,"method":"session/new","params":{"cwd":"/nonexistent/path/xyz"}}"#,
2715 );
2716 let lines = run_server(input).await;
2717 assert_eq!(lines.len(), 3, "init + initialized + error");
2718 let v = parse_line(&lines, 2);
2719 assert!(v["error"].is_object());
2720 assert_ne!(v["error"]["code"], 0);
2721 let msg = v["error"]["message"].as_str().unwrap();
2722 assert!(msg.contains("cwd"), "msg should mention cwd: {msg}");
2723 }
2724
2725 #[tokio::test]
2730 async fn session_prompt_invalid_session_returns_error() {
2731 let input = concat!(
2732 r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1}}"#,
2733 "\n",
2734 r#"{"jsonrpc":"2.0","id":2,"method":"session/prompt","params":{"sessionId":"nonexistent","prompt":[{"type":"text","text":"hi"}]}}"#,
2735 );
2736 let lines = run_server(input).await;
2737 assert_eq!(lines.len(), 3, "init + initialized + error");
2738 let v = parse_line(&lines, 2);
2739 assert!(v["error"].is_object());
2740 assert_eq!(v["error"]["code"], SESSION_NOT_FOUND);
2741 let msg = v["error"]["message"].as_str().unwrap();
2742 assert!(msg.contains("not found"), "msg: {msg}");
2743 }
2744
2745 #[tokio::test]
2746 async fn session_prompt_emits_agent_message_chunk_and_stop_reason() {
2747 let tmp = tempfile::TempDir::new().unwrap();
2748 let cwd = json_path(tmp.path());
2749
2750 let phase1_input = format!(
2752 r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":1}}}}
2753{{"jsonrpc":"2.0","id":2,"method":"session/new","params":{{"cwd":{}}}}}"#,
2754 cwd
2755 );
2756 let reader1 = std::io::Cursor::new(phase1_input.as_bytes().to_owned());
2757 let mut output1 = Vec::<u8>::new();
2758 AcpServer::run_io(reader1, &mut output1, Some(mock_llm())).await;
2759 let lines1: Vec<String> = String::from_utf8(output1)
2760 .unwrap()
2761 .lines()
2762 .map(|s| s.to_string())
2763 .collect();
2764 let sn_resp: Value = serde_json::from_str(&lines1[2]).unwrap();
2765 let sid = sn_resp["result"]["sessionId"].as_str().unwrap().to_string();
2766
2767 let phase2_input = format!(
2769 r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":1}}}}
2770{{"jsonrpc":"2.0","id":2,"method":"session/new","params":{{"cwd":{}}}}}
2771{{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{{"sessionId":"{}","prompt":[{{"type":"text","text":"hello"}}]}}}}"#,
2772 cwd, sid
2773 );
2774 let reader2 = std::io::Cursor::new(phase2_input.as_bytes().to_owned());
2775 let mut output2 = Vec::<u8>::new();
2776 AcpServer::run_io(reader2, &mut output2, Some(mock_llm())).await;
2777 let lines2: Vec<String> = String::from_utf8(output2)
2778 .unwrap()
2779 .lines()
2780 .map(|s| s.to_string())
2781 .collect();
2782
2783 assert!(
2784 lines2.len() >= 4,
2785 "expected at least 4 lines, got {}",
2786 lines2.len()
2787 );
2788
2789 let prompt_resp_line = lines2
2791 .iter()
2792 .rposition(|l| l.contains(r#""id":3"#))
2793 .expect("should have session/prompt response");
2794 let prompt_resp: Value = serde_json::from_str(&lines2[prompt_resp_line]).unwrap();
2795 assert_eq!(prompt_resp["result"]["stopReason"], "end_turn");
2796
2797 let notifs = find_notifications(&lines2);
2799 assert!(!notifs.is_empty(), "expected session/update notifications");
2800
2801 let has_chunk = notifs.iter().any(|n| {
2803 n["params"]["update"]["sessionUpdate"] == "agent_message_chunk"
2804 && n["params"]["update"]["content"]["type"] == "text"
2805 && !n["params"]["update"]["content"]["text"]
2806 .as_str()
2807 .unwrap_or("")
2808 .is_empty()
2809 });
2810 assert!(has_chunk, "expected agent_message_chunk with text content");
2811
2812 let last_notif = notifs.last().unwrap();
2814 assert_eq!(
2815 last_notif["params"]["update"]["stopReason"], "end_turn",
2816 "last notification must have stopReason=end_turn"
2817 );
2818 }
2819
2820 #[tokio::test]
2821 async fn session_prompt_content_block_text_concatenation() {
2822 let tmp = tempfile::TempDir::new().unwrap();
2823 let cwd = json_path(tmp.path());
2824
2825 let echo_llm = mock_llm_multi(vec![crate::llm::Completion {
2826 content: "hello world".into(),
2827 ..Default::default()
2828 }]);
2829
2830 let input = format!(
2831 r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":1}}}}
2832{{"jsonrpc":"2.0","id":2,"method":"session/new","params":{{"cwd":{}}}}}
2833{{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{{"sessionId":"acp-sess-1","prompt":[{{"type":"text","text":"hello"}},{{"type":"text","text":" world"}}]}}}}"#,
2834 cwd
2835 );
2836
2837 let reader = std::io::Cursor::new(input.as_bytes().to_owned());
2838 let mut output = Vec::<u8>::new();
2839 AcpServer::run_io(reader, &mut output, Some(echo_llm)).await;
2840 let text = String::from_utf8(output).unwrap();
2841 assert!(!text.is_empty());
2842 }
2843
2844 #[tokio::test]
2845 async fn session_prompt_non_text_blocks_ignored() {
2846 let tmp = tempfile::TempDir::new().unwrap();
2847 let cwd = json_path(tmp.path());
2848
2849 let input = format!(
2850 r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":1}}}}
2851{{"jsonrpc":"2.0","id":2,"method":"session/new","params":{{"cwd":{}}}}}
2852{{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{{"sessionId":"acp-sess-1","prompt":[{{"type":"image","data":"AAAA","mimeType":"image/png"}}]}}}}"#,
2853 cwd
2854 );
2855
2856 let reader = std::io::Cursor::new(input.as_bytes().to_owned());
2857 let mut output = Vec::<u8>::new();
2858 AcpServer::run_io(reader, &mut output, Some(mock_llm())).await;
2859 let text = String::from_utf8(output).unwrap();
2860 assert!(!text.is_empty());
2861 assert!(
2862 text.contains("Unsupported"),
2863 "should mention unsupported types"
2864 );
2865 }
2866
2867 #[tokio::test]
2868 async fn session_prompt_missing_prompt_field_returns_error() {
2869 let tmp = tempfile::TempDir::new().unwrap();
2870 let cwd = json_path(tmp.path());
2871 let input = format!(
2872 r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":1}}}}
2873{{"jsonrpc":"2.0","id":2,"method":"session/new","params":{{"cwd":{}}}}}
2874{{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{{"sessionId":"acp-sess-1"}}}}"#,
2875 cwd
2876 );
2877 let lines = run_server(&input).await;
2878 let v = parse_line(&lines, 3);
2881 assert_eq!(v["error"]["code"], INVALID_PARAMS);
2882 }
2883
2884 #[tokio::test]
2889 async fn multi_turn_context_preserved() {
2890 let tmp = tempfile::TempDir::new().unwrap();
2891 let cwd = json_path(tmp.path());
2892
2893 let multi_llm = mock_llm_multi(vec![
2894 crate::llm::Completion {
2895 content: "Nice to meet you, Alice!".into(),
2896 ..Default::default()
2897 },
2898 crate::llm::Completion {
2899 content: "Your name is Alice.".into(),
2900 ..Default::default()
2901 },
2902 ]);
2903
2904 let phase1_input = format!(
2906 r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":1}}}}
2907{{"jsonrpc":"2.0","id":2,"method":"session/new","params":{{"cwd":{}}}}}"#,
2908 cwd
2909 );
2910 let reader1 = std::io::Cursor::new(phase1_input.as_bytes().to_owned());
2911 let mut output1 = Vec::<u8>::new();
2912 let llm1 = mock_llm_multi(vec![crate::llm::Completion {
2913 content: "Nice to meet you, Alice!".into(),
2914 ..Default::default()
2915 }]);
2916 AcpServer::run_io(reader1, &mut output1, Some(llm1)).await;
2917 let lines1: Vec<String> = String::from_utf8(output1)
2918 .unwrap()
2919 .lines()
2920 .map(|s| s.to_string())
2921 .collect();
2922 let sn_resp: Value = serde_json::from_str(&lines1[2]).unwrap();
2923 let sid = sn_resp["result"]["sessionId"].as_str().unwrap().to_string();
2924
2925 let phase2_input = format!(
2927 r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":1}}}}
2928{{"jsonrpc":"2.0","id":2,"method":"session/new","params":{{"cwd":{}}}}}
2929{{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{{"sessionId":"{}","prompt":[{{"type":"text","text":"my name is Alice"}}]}}}}
2930{{"jsonrpc":"2.0","id":4,"method":"session/prompt","params":{{"sessionId":"{}","prompt":[{{"type":"text","text":"what is my name?"}}]}}}}"#,
2931 cwd, sid, sid
2932 );
2933 let reader2 = std::io::Cursor::new(phase2_input.as_bytes().to_owned());
2934 let mut output2 = Vec::<u8>::new();
2935 AcpServer::run_io(reader2, &mut output2, Some(multi_llm)).await;
2936 let text = String::from_utf8(output2).unwrap();
2937 assert!(
2938 text.contains("Alice"),
2939 "second turn should retain context about Alice, got:\n{text}"
2940 );
2941 }
2942
2943 #[tokio::test]
2949 async fn ac21_session_new_path_traversal_rejected_or_normalized() {
2950 let tmp = tempfile::TempDir::new().unwrap();
2951 let traversal = json_path(&tmp.path().join("../../../etc"));
2952 let input = format!(
2953 r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":1}}}}
2954{{"jsonrpc":"2.0","id":2,"method":"session/new","params":{{"cwd":{}}}}}"#,
2955 traversal
2956 );
2957 let lines = run_server(&input).await;
2958 let v = parse_line(&lines, 2);
2959 if v["result"].is_object() {
2960 eprintln!(
2961 "AC-2.1 NOTE: path traversal cwd accepted (canonicalize resolved it). \
2962 Path was: {traversal}"
2963 );
2964 } else {
2965 assert!(
2966 v["error"].is_object(),
2967 "path traversal must error or be safely normalized"
2968 );
2969 }
2970 }
2971
2972 #[tokio::test]
2974 async fn ac21_session_new_stable_id_valid_for_prompt() {
2975 let tmp = tempfile::TempDir::new().unwrap();
2976 let cwd = json_path(tmp.path());
2977 let input = format!(
2978 r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":1}}}}
2979{{"jsonrpc":"2.0","id":2,"method":"session/new","params":{{"cwd":{}}}}}
2980{{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{{"sessionId":"acp-sess-1","prompt":[{{"type":"text","text":"hello"}}]}}}}"#,
2981 cwd
2982 );
2983 let lines = run_server(&input).await;
2984 let sn_resp = parse_line(&lines, 2);
2985 assert!(sn_resp["result"]["sessionId"].is_string());
2986 assert!(!sn_resp["result"]["sessionId"].as_str().unwrap().is_empty());
2987 let pr_resp = parse_line(&lines, 3);
2988 assert!(
2989 pr_resp["result"].is_object(),
2990 "session/prompt must succeed with valid sessionId, got: {pr_resp}"
2991 );
2992 }
2993
2994 #[tokio::test]
2996 async fn ac21_nonexistent_cwd_returns_error() {
2997 let input = concat!(
2998 r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1}}"#,
2999 "\n",
3000 r#"{"jsonrpc":"2.0","id":2,"method":"session/new","params":{"cwd":"/nonexistent/path/xyz123"}}"#,
3001 );
3002 let lines = run_server(input).await;
3003 let v = parse_line(&lines, 2);
3004 assert!(v["error"].is_object(), "nonexistent cwd must return error");
3005 let msg = v["error"]["message"].as_str().unwrap_or("").to_lowercase();
3006 assert!(
3007 msg.contains("cwd") && (msg.contains("not exist") || msg.contains("does not")),
3008 "error must mention cwd, got: '{msg}'"
3009 );
3010 }
3011
3012 #[tokio::test]
3014 async fn ac22_session_prompt_streams_chunks_and_end_turn() {
3015 let tmp = tempfile::TempDir::new().unwrap();
3016 let cwd = json_path(tmp.path());
3017 let input = format!(
3018 r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":1}}}}
3019{{"jsonrpc":"2.0","id":2,"method":"session/new","params":{{"cwd":{}}}}}
3020{{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{{"sessionId":"acp-sess-1","prompt":[{{"type":"text","text":"Say hello and list the current directory"}}]}}}}"#,
3021 cwd
3022 );
3023 let lines = run_server(&input).await;
3024 let notifs = find_notifications(&lines);
3025 let has_chunk = notifs.iter().any(|n| {
3026 let upd = &n["params"]["update"];
3027 upd["sessionUpdate"] == "agent_message_chunk"
3028 && !upd["content"]["text"].as_str().unwrap_or("").is_empty()
3029 });
3030 assert!(
3031 has_chunk,
3032 "must have agent_message_chunk with non-empty text"
3033 );
3034 let last = notifs.last().expect("must have notifications");
3035 assert_eq!(last["params"]["update"]["sessionUpdate"], "end_turn");
3036 let reason = last["params"]["update"]["stopReason"]
3037 .as_str()
3038 .unwrap_or("");
3039 assert!(!reason.is_empty(), "stopReason must not be empty");
3040 }
3041
3042 #[tokio::test]
3044 async fn ac22_end_turn_is_last_notification() {
3045 let tmp = tempfile::TempDir::new().unwrap();
3046 let cwd = json_path(tmp.path());
3047 let input = format!(
3048 r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":1}}}}
3049{{"jsonrpc":"2.0","id":2,"method":"session/new","params":{{"cwd":{}}}}}
3050{{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{{"sessionId":"acp-sess-1","prompt":[{{"type":"text","text":"hello"}}]}}}}"#,
3051 cwd
3052 );
3053 let lines = run_server(&input).await;
3054 let notifs = find_notifications(&lines);
3055 let last = notifs.last().expect("must have notifications");
3056 assert_eq!(
3057 last["params"]["update"]["sessionUpdate"], "end_turn",
3058 "last notification must be end_turn, got: {}",
3059 last["params"]["update"]["sessionUpdate"]
3060 );
3061 }
3062
3063 #[tokio::test]
3065 async fn ac23_mixed_text_and_non_text_no_crash() {
3066 let tmp = tempfile::TempDir::new().unwrap();
3067 let cwd = json_path(tmp.path());
3068 let input = format!(
3069 r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":1}}}}
3070{{"jsonrpc":"2.0","id":2,"method":"session/new","params":{{"cwd":{}}}}}
3071{{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{{"sessionId":"acp-sess-1","prompt":[{{"type":"text","text":"hello"}},{{"type":"resource_link","uri":"file:///workspace/project/README.md"}},{{"type":"image","uri":"data:image/png;base64,iVBORw0KGgo="}}]}}}}"#,
3072 cwd
3073 );
3074 let lines = run_server(&input).await;
3075 let pr_resp = parse_line(&lines, 3);
3076 assert!(
3077 pr_resp["result"].is_object() || pr_resp["error"].is_object(),
3078 "must return result or error, not crash"
3079 );
3080 }
3081
3082 #[tokio::test]
3084 async fn ac23_only_non_text_blocks_returns_graceful() {
3085 let tmp = tempfile::TempDir::new().unwrap();
3086 let cwd = json_path(tmp.path());
3087 let input = format!(
3088 r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":1}}}}
3089{{"jsonrpc":"2.0","id":2,"method":"session/new","params":{{"cwd":{}}}}}
3090{{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{{"sessionId":"acp-sess-1","prompt":[{{"type":"resource_link","uri":"file:///workspace/README.md"}}]}}}}"#,
3091 cwd
3092 );
3093 let lines = run_server(&input).await;
3094 let pr_resp = parse_line(&lines, 3);
3095 if pr_resp["result"].is_object() {
3096 let reason = pr_resp["result"]["stopReason"]
3097 .as_str()
3098 .unwrap_or("MISSING");
3099 assert_eq!(
3100 reason, "end_turn",
3101 "only-non-text blocks must yield end_turn"
3102 );
3103 } else {
3104 assert!(pr_resp["error"].is_object(), "must return error or result");
3105 }
3106 let has_response = lines.iter().any(|l| l.contains(r#""id":3"#));
3107 assert!(has_response, "request id=3 must have a response");
3108 }
3109
3110 #[tokio::test]
3112 async fn ac24_multi_turn_context_preserved() {
3113 let tmp = tempfile::TempDir::new().unwrap();
3114 let cwd = json_path(tmp.path());
3115 let multi_llm = mock_llm_multi(vec![
3116 crate::llm::Completion {
3117 content: "Got it, Alice. I'll remember stellar.".into(),
3118 ..Default::default()
3119 },
3120 crate::llm::Completion {
3121 content: "Your name is Alice and you work on stellar.".into(),
3122 ..Default::default()
3123 },
3124 ]);
3125 let input = format!(
3126 r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":1}}}}
3127{{"jsonrpc":"2.0","id":2,"method":"session/new","params":{{"cwd":{}}}}}
3128{{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{{"sessionId":"acp-sess-1","prompt":[{{"type":"text","text":"My name is Alice and I'm working on a Rust project called stellar"}}]}}}}
3129{{"jsonrpc":"2.0","id":4,"method":"session/prompt","params":{{"sessionId":"acp-sess-1","prompt":[{{"type":"text","text":"What's my name and what project am I working on?"}}]}}}}"#,
3130 cwd
3131 );
3132 let lines = run_server_with_llm(&input, Some(multi_llm)).await;
3133 let text = lines.join("\n");
3134 assert!(text.contains("Alice"), "second turn must mention Alice");
3135 assert!(text.contains("stellar"), "second turn must mention stellar");
3136 let notifs = find_notifications(&lines);
3137 let end_turns: Vec<_> = notifs
3138 .iter()
3139 .filter(|n| n["params"]["update"]["sessionUpdate"] == "end_turn")
3140 .collect();
3141 assert!(
3142 end_turns.len() >= 2,
3143 "must have ≥2 end_turns, got {}",
3144 end_turns.len()
3145 );
3146 assert_eq!(
3147 end_turns.last().unwrap()["params"]["update"]["stopReason"],
3148 "end_turn"
3149 );
3150 }
3151
3152 #[tokio::test]
3154 async fn ac25_stop_reason_end_turn_for_normal_completion() {
3155 let tmp = tempfile::TempDir::new().unwrap();
3156 let cwd = json_path(tmp.path());
3157 let input = format!(
3158 r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":1}}}}
3159{{"jsonrpc":"2.0","id":2,"method":"session/new","params":{{"cwd":{}}}}}
3160{{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{{"sessionId":"acp-sess-1","prompt":[{{"type":"text","text":"hello"}}]}}}}"#,
3161 cwd
3162 );
3163 let lines = run_server(&input).await;
3164 let pr_resp = parse_line(&lines, 3);
3165 assert_eq!(pr_resp["result"]["stopReason"], "end_turn");
3166 let notifs = find_notifications(&lines);
3167 let end_turns: Vec<_> = notifs
3168 .iter()
3169 .filter(|n| n["params"]["update"]["sessionUpdate"] == "end_turn")
3170 .collect();
3171 assert_eq!(end_turns.len(), 1, "must have exactly one end_turn");
3172 }
3173
3174 #[tokio::test]
3176 async fn ac26_message_id_deterministic_across_sessions() {
3177 let tmp = tempfile::TempDir::new().unwrap();
3178 let cwd = json_path(tmp.path());
3179 let mk_llm = || {
3180 mock_llm_multi(vec![crate::llm::Completion {
3181 content: "Hi!".into(),
3182 ..Default::default()
3183 }])
3184 };
3185
3186 let extract_user_msg_id = |lines: &[String]| -> Option<String> {
3187 lines
3188 .iter()
3189 .filter_map(|l| {
3190 let v: Value = serde_json::from_str(l).ok()?;
3191 if v["method"] == "session/update"
3192 && v["params"]["update"]["sessionUpdate"] == "user_message_chunk"
3193 {
3194 v["params"]["update"]["messageId"]
3195 .as_str()
3196 .map(|s| s.to_string())
3197 } else {
3198 None
3199 }
3200 })
3201 .next()
3202 };
3203
3204 let input_sess = |text: &str| -> String {
3205 format!(
3206 r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":1}}}}
3207{{"jsonrpc":"2.0","id":2,"method":"session/new","params":{{"cwd":{}}}}}
3208{{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{{"sessionId":"acp-sess-1","prompt":[{{"type":"text","text":"{}"}}]}}}}"#,
3209 cwd, text
3210 )
3211 };
3212
3213 let lines_a = run_server_with_llm(&input_sess("Hello world"), Some(mk_llm())).await;
3214 let lines_b = run_server_with_llm(&input_sess("Hello world"), Some(mk_llm())).await;
3215 let id_a = extract_user_msg_id(&lines_a).expect("A must have user_message_chunk");
3216 let id_b = extract_user_msg_id(&lines_b).expect("B must have user_message_chunk");
3217
3218 assert_eq!(id_a, id_b, "same content must produce same messageId");
3219 assert_eq!(id_a.len(), 12, "messageId must be 12 chars");
3220 assert!(
3221 id_a.chars()
3222 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
3223 "messageId must be lowercase hex, got: {id_a}"
3224 );
3225
3226 let lines_c = run_server_with_llm(&input_sess("Different prompt"), Some(mk_llm())).await;
3227 let id_c = extract_user_msg_id(&lines_c).expect("C must have user_message_chunk");
3228 assert_ne!(
3229 id_a, id_c,
3230 "different prompts must give different messageIds"
3231 );
3232 }
3233
3234 #[tokio::test]
3236 async fn ac27_event_sink_only_allowed_notifications() {
3237 let tmp = tempfile::TempDir::new().unwrap();
3238 let cwd = json_path(tmp.path());
3239 let input = format!(
3240 r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":1}}}}
3241{{"jsonrpc":"2.0","id":2,"method":"session/new","params":{{"cwd":{}}}}}
3242{{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{{"sessionId":"acp-sess-1","prompt":[{{"type":"text","text":"Say hello and explain what you can do"}}]}}}}"#,
3243 cwd
3244 );
3245 let lines = run_server(&input).await;
3246 let notifs = find_notifications(&lines);
3247 assert!(!notifs.is_empty(), "must have notifications");
3248 let allowed = ["user_message_chunk", "agent_message_chunk", "end_turn"];
3249 for n in ¬ifs {
3250 let ut = n["params"]["update"]["sessionUpdate"]
3251 .as_str()
3252 .unwrap_or("MISSING");
3253 assert!(
3254 allowed.contains(&ut),
3255 "Sprint 2 must not emit '{}', got: {n}",
3256 ut
3257 );
3258 }
3259 let end_turn = notifs
3260 .iter()
3261 .find(|n| n["params"]["update"]["sessionUpdate"] == "end_turn")
3262 .expect("must have end_turn");
3263 assert_eq!(end_turn["params"]["sessionId"], "acp-sess-1");
3264 assert!(!end_turn["params"]["update"]["stopReason"]
3265 .as_str()
3266 .unwrap_or("")
3267 .is_empty());
3268 assert!(!end_turn["params"]["update"]["turnId"]
3269 .as_str()
3270 .unwrap_or("")
3271 .is_empty());
3272 for n in ¬ifs {
3273 assert_eq!(n["jsonrpc"], "2.0");
3274 assert_eq!(n["method"], "session/update");
3275 }
3276 }
3277
3278 #[tokio::test]
3280 async fn ac29_stale_session_returns_error_not_crash() {
3281 let tmp = tempfile::TempDir::new().unwrap();
3282 let cwd = json_path(tmp.path());
3283 let input = format!(
3284 r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":1}}}}
3285{{"jsonrpc":"2.0","id":2,"method":"session/prompt","params":{{"sessionId":"nonexistent-session-id","prompt":[{{"type":"text","text":"hi"}}]}}}}
3286{{"jsonrpc":"2.0","id":3,"method":"session/new","params":{{"cwd":{}}}}}"#,
3287 cwd
3288 );
3289 let lines = run_server(&input).await;
3290 let err_resp = parse_line(&lines, 2);
3291 assert!(
3292 err_resp["error"].is_object(),
3293 "nonexistent session must return error"
3294 );
3295 assert_eq!(err_resp["error"]["code"], SESSION_NOT_FOUND);
3296 assert!(!err_resp["error"]["message"]
3297 .as_str()
3298 .unwrap_or("")
3299 .is_empty());
3300 let ok_resp = parse_line(&lines, 3);
3301 assert!(
3302 ok_resp["result"].is_object(),
3303 "server must stay responsive, got: {ok_resp}"
3304 );
3305 }
3306
3307 #[tokio::test]
3309 async fn ac210_empty_prompt_array_returns_error_or_helpful() {
3310 let tmp = tempfile::TempDir::new().unwrap();
3311 let cwd = json_path(tmp.path());
3312 let input = format!(
3313 r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":1}}}}
3314{{"jsonrpc":"2.0","id":2,"method":"session/new","params":{{"cwd":{}}}}}
3315{{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{{"sessionId":"acp-sess-1","prompt":[]}}}}"#,
3316 cwd
3317 );
3318 let lines = run_server(&input).await;
3319 let pr_resp = parse_line(&lines, 3);
3320 if pr_resp["error"].is_object() {
3321 let msg = pr_resp["error"]["message"].as_str().unwrap_or("");
3322 assert!(!msg.is_empty(), "error message must be descriptive");
3323 } else if pr_resp["result"].is_object() {
3324 let reason = pr_resp["result"]["stopReason"]
3325 .as_str()
3326 .unwrap_or("MISSING");
3327 assert!(!reason.is_empty(), "stopReason must be present");
3328 } else {
3329 panic!("neither result nor error: {pr_resp}");
3330 }
3331 for line in &lines {
3332 let _: Value =
3333 serde_json::from_str(line).unwrap_or_else(|_| panic!("malformed JSON: {line}"));
3334 }
3335 }
3336}