Skip to main content

recursive/acp/
server.rs

1//! # Collaborative Cancellation
2//!
3//! Cancel is a **request** (not a demand). The agent drains in-flight tool results
4//! before stopping, and `stopReason: 'cancelled'` is set on the last assistant message.
5//! See Decision #4c.
6//!
7//! ## Transport contract
8//!
9//! - **Framing**: newline-delimited JSON. One JSON-RPC message per line;
10//!   no whitespace padding, no length prefix.
11//! - **Request/response matching**: each request carries an `id` field
12//!   (string or number). The server echoes the same `id` in its response.
13//! - **Notifications**: a JSON-RPC message without an `id` field is a
14//!   notification. Notifications are consumed silently — no response is
15//!   written to stdout.
16//! - **Batch semantics**: when the input line is a JSON array, the server
17//!   treats it as a batch. Every element is processed independently and
18//!   the server writes back a JSON array of responses (notifications
19//!   excluded). An empty batch is an error (-32600).
20//! - **Stdout/stderr contract**: every byte written to stdout is valid
21//!   JSON-RPC (`"jsonrpc":"2.0"` present). All logging, diagnostics, and
22//!   tracing output goes to stderr only.
23//!
24//! # Handshake state machine
25//!
26//! The server tracks initialization state:
27//! - **Uninitialized** (startup): only `initialize` and method-not-found
28//!   responses are produced. Known session methods (`session/new`,
29//!   `session/prompt`, `session/cancel`) return `-32002`
30//!   ("Server not initialized"). Truly unknown methods return
31//!   `-32601` ("Method not found") regardless of state per the
32//!   JSON-RPC 2.0 spec.
33//! - **Initialized** (after successful `initialize`): `initialize` is
34//!   rejected as a duplicate (`-32002`). Other methods are dispatched.
35//!
36//! # Cooperative Cancel
37//!
38//! `session/cancel` implements a **two-phase cooperative abort**:
39//!
40//! **Phase 1 — LLM stream abort.** Each provider's SSE parse loop
41//! (`OpenAiProvider::parse_sse_stream`, `AnthropicProvider::parse_sse_stream`)
42//! uses `tokio::select!` on a [`tokio_util::sync::CancellationToken`] alongside
43//! the SSE chunk read. When the token fires, the `reqwest::Response` is dropped
44//! (closing the TCP connection) and the stream returns `Err(Error::Cancelled)`.
45//!
46//! **Phase 2 — ACP client RPC abort.** Agent→client RPCs (e.g.
47//! `session/request_permission`) are also guarded by the same `CancellationToken`
48//! plus a 30-second timeout. If `session/cancel` arrives while the agent is
49//! blocked waiting for a client response, the token fires and unblocks the RPC
50//! with a cancelled error.
51//!
52//! **Token lifecycle.** `session/new` materializes a fresh `CancellationToken`
53//! and stores it on the [`AcpSession`]. `session/cancel` fires that token.
54//! The same token instance is passed to
55//! [`AgentRuntime::set_interrupt_token`](crate::runtime::AgentRuntime::set_interrupt_token)
56//! exactly once at the start of each turn. After a turn ends (regardless of
57//! finish reason), a **fresh `CancellationToken` is created** for the next
58//! turn — a cancel only affects the currently in-flight turn, never a future one.
59//!
60//! **Transcript repair.** After a cancelled turn, the transcript remains
61//! structurally valid: every assistant message containing `tool_calls` has a
62//! corresponding tool result message. In-flight tool calls that were cancelled
63//! mid-execution get a synthetic `tool_result` with content indicating
64//! cancellation, so `session/load` replays without orphaned `tool_calls` and
65//! subsequent turns work correctly.
66
67use 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
89// ---------------------------------------------------------------------------
90// JSON-RPC 2.0 error codes
91// ---------------------------------------------------------------------------
92
93const 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;
98/// Custom error: session not found or not running (S1-C12: contract requires -32000).
99const SESSION_NOT_FOUND: i32 = -32000;
100
101/// Timeout for all in-flight agent→client fs/* RPCs, in milliseconds.
102/// Override via the `ACP_CLIENT_RPC_TIMEOUT_MS` environment variable.
103/// Default: 30_000 ms (30 seconds).
104#[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
112/// Supported protocol version.
113const SUPPORTED_PROTOCOL_VERSION: ProtocolVersion = ProtocolVersion::V1;
114
115// ---------------------------------------------------------------------------
116// Server state machine
117// ---------------------------------------------------------------------------
118
119/// Tracks whether the client has completed the `initialize` handshake.
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121enum ServerState {
122    /// Waiting for the first `initialize` request.
123    Uninitialized,
124    /// Handshake complete; further `initialize` calls are rejected.
125    Initialized,
126}
127
128// ---------------------------------------------------------------------------
129// Raw JSON-RPC wire types (minimal, for dispatch)
130// ---------------------------------------------------------------------------
131
132/// Minimal parsed JSON-RPC request envelope.
133#[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
145// ---------------------------------------------------------------------------
146// Response builders
147// ---------------------------------------------------------------------------
148
149/// Build a JSON-RPC 2.0 success response.
150fn build_success(id: &Value, result: Value) -> Value {
151    serde_json::json!({
152        "jsonrpc": "2.0",
153        "id": id,
154        "result": result,
155    })
156}
157
158/// Build a JSON-RPC 2.0 error response.
159fn 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
170/// Write a JSON line to the writer (used in run_io loop).
171async 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
182// ---------------------------------------------------------------------------
183// Method dispatch — sync dispatch table (HashMap) + async fallback
184// ---------------------------------------------------------------------------
185
186/// Synchronous method handler signature: `(id, params) -> response Value`.
187type SyncMethodHandler = fn(id: &Value, params: Option<&Value>) -> Value;
188
189/// Build the Sprint-1 synchronous dispatch table.
190/// Only `initialize` is mapped; all other methods fall through to
191/// the async dispatch (session/*) or return MethodNotFound.
192fn 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
198/// Dispatch a single JSON-RPC request envelope for async session methods.
199///
200/// `sessions` is the session manager; session/new creates entries,
201/// session/prompt looks them up.
202///
203/// `llm` is the shared LLM provider used to construct AgentRuntimes for
204/// new sessions. `None` means the server runs in Sprint-1-only mode
205/// where all non-`initialize` methods return MethodNotFound.
206///
207/// Returns `(response, notifications)`. The caller writes the response
208/// first, then any notifications. This ordering guarantees that the
209/// `initialized` notification appears after the initialize response
210/// on the wire, satisfying C2.
211async 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        // ── Sprint 2: session/new ────────────────────────────────
221        "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        // ── Sprint 2: session/prompt ─────────────────────────────
244        "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        // ── Sprint 4: session/cancel ─────────────────────────────
267        "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        // ── ACP-S1-01: session/load ───────────────────────────────
280        "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        // ── ACP-S1-03: session/resume ─────────────────────────────
303        "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        // ── ACP-S1-04: session/request_permission (client response) ──
326        "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        // ── S2-E11: session/close ─────────────────────────────────
342        "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
365/// Handle the `initialize` method.
366///
367/// Returns the agent info, protocol version, and forward-capability
368/// declaration (8 keys per S1-C2: `promptCapabilities`, `toolCallNotifications`,
369/// `loadSession`, `resume`, `fs.readTextFile`, `fs.writeTextFile`,
370/// `mcpCapabilities`, `terminalCapabilities` where the last is explicitly
371/// `false`).
372fn 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    // Sprint 1 contract: 8 forward-capability keys.
407    // loadSession and resume are always advertised as available (the server
408    // implements these features regardless of what the client declares).
409    // terminalCapabilities is explicitly false.
410    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
430// ---------------------------------------------------------------------------
431// session/new handler
432// ---------------------------------------------------------------------------
433
434/// Handle `session/new`: create a sandboxed session.
435///
436/// Extracts `cwd` from params, validates the path exists and is a directory,
437/// builds an [`AgentRuntime`] with an [`AcpBridge`] event sink, and stores
438/// the session in the manager.
439async 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    // Extract cwd
460    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    // Sandbox cwd via resolve_within: canonicalise the path to resolve
475    // `..` segments and symlinks, then verify it exists and is a directory.
476    let cwd = match std::path::PathBuf::from(cwd_str).canonicalize() {
477        Ok(p) => {
478            // Validate it's a directory after canonicalisation.
479            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    // Contract AC-2.1: optional sandbox check.
504    // cwd must be a subdirectory of (or equal to) the configured sandbox root,
505    // which defaults to process.cwd but can be set via `RECURSIVE_ACP_SANDBOX_ROOT`.
506    // The check is **skipped by default in tests/dev** and **required in
507    // production** by setting `RECURSIVE_ACP_SANDBOX_STRICT=1`. This split
508    // keeps test ergonomics (TempDir cwd) while letting prod deployments
509    // catch path traversal.
510    //
511    // Without the strict flag, we still log a warning if cwd looks escaped
512    // (canonicalised path is outside process.cwd) so operators can spot
513    // misconfigurations.
514    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    // Generate session id first (we need it for the bridge)
541    let session_id = sessions.next_session_id();
542
543    // Create the bridge for event streaming (Sprint 2: turn 0, no tool notifications)
544    let (bridge, _rx) = AcpBridge::new(
545        session_id.clone(),
546        std::collections::HashMap::new(),
547        0,
548        false,
549    );
550
551    // Build the AgentRuntime with streaming enabled
552    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    // Extract optional systemPrompt and mcpServers from params
572    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    // S2-E1/E3: configure ACP client FS state based on client capabilities
579    // declared during initialize. The acp_state controls whether
580    // ClientReadFile/ClientWriteFile are visible and routable.
581    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    // Store the session (note: we use the pre-generated id via insert_with_id)
596    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    // S2-E20: if client has declared fs capabilities, register ClientReadFile/ClientWriteFile
613    // as additional tools on the session's runtime tool registry.
614    {
615        let state = acp_state.lock().await;
616        if state.read_text_file || state.write_text_file {
617            // Get the existing tool registry and add client FS tools
618            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                // Register on the session's runtime tool registry
626                // The runtime builder stored the registry; we can access it via
627                // the session after insertion
628                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
646// ---------------------------------------------------------------------------
647// session/load handler (ACP-S1-01 / ACP-S1-04 / ACP-S1-06 / ACP-S1-13)
648// ---------------------------------------------------------------------------
649
650/// Handle `session/load`: replay full conversation history via notification
651/// stream without re-executing any tools.
652///
653/// 1. Loads the session transcript and metadata from disk.
654/// 2. Generates a local-heuristic summary and injects it into the system
655///    prompt (ACP-S1-04 — no LLM call).
656/// 3. Creates a fresh [`AgentRuntime`] and [`AcpSession`] in the manager.
657/// 4. Replays every message as a `session/update` notification (user,
658///    assistant, tool) in correct chronological order.
659/// 5. Final notification is `session/loaded`.
660///
661/// Error cases per ACP-S1-13:
662/// - Non-existent session ID → -32000 error.
663/// - Corrupted session file → -32000 error with parse-failure message.
664async 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    // Extract sessionId
685    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    // ACP-S1-13a: non-existent session ID → -32000 error
700    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    // ACP-S1-13b: corrupt session file → -32000 with parse-failure message
712    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    // ACP-S1-04: generate summary via local heuristic (no LLM call)
741    let messages: Vec<crate::message::Message> =
742        saved_messages.iter().map(|sm| sm.message.clone()).collect();
743    let summarized = summarize_transcript(&messages);
744
745    // Inject summary into system prompt
746    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    // Extract optional mcpServers override from params (ACP-S1-06)
752    let mcp_servers = params
753        .get("mcpServers")
754        .cloned()
755        .or(metadata.mcp_servers.clone());
756
757    // Build fresh AgentRuntime with a new bridge
758    let turn = metadata.turn;
759    let (bridge, _rx) = AcpBridge::new(
760        sid.clone(),
761        std::collections::HashMap::new(),
762        turn,
763        true, // tool_call notifications enabled for loaded sessions
764    );
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    // ACP-S1-16: wrap cancel token in Arc
787    let cancel_token = Arc::new(tokio_util::sync::CancellationToken::new());
788
789    // S2-E13: kill old MCP child processes before replacing the session
790    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    // ACP-S1-01: replay each message as a session/update notification in order.
811    // Emit tool_call + tool_call_update for assistant messages with tool_calls,
812    // and tool_call_update for tool result messages (S1.1).
813    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                // If the assistant message has tool calls, emit tool_call
837                // (pending) + tool_call_update (in_progress) for each.
838                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                // Emit tool_call_update for the tool result.
889                // Use the tool_call_id from the message if available,
890                // otherwise fall back to sm.id (content hash).
891                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                // System messages are not replayed as notifications
912            }
913        }
914    }
915
916    // ACP-S1-01: final notification is session/loaded
917    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    // S1.1: session/load returns result=null per contract.
931    (
932        Some(build_success(id, serde_json::Value::Null)),
933        notifications,
934    )
935}
936
937// ---------------------------------------------------------------------------
938// session/resume handler (ACP-S1-03 / ACP-S1-05 / ACP-S1-06 / ACP-S1-13)
939// ---------------------------------------------------------------------------
940
941/// Handle `session/resume`: restore agent context (system prompt, conversation
942/// history, MCP connections) without replaying every notification.
943///
944/// 1. Loads the session transcript and metadata from disk.
945/// 2. Computes compaction hints for the transcript (ACP-S1-05).
946/// 3. Creates a fresh [`AgentRuntime`] and [`AcpSession`] in the manager.
947/// 4. Returns the restored context (system_prompt, conversation history,
948///    compaction_hints) — **no** `session/update` notifications are emitted.
949///
950/// Error cases per ACP-S1-13:
951/// - Non-existent session ID → -32000 error.
952/// - Invalid mcpServers config → logged as warning, gracefully skipped.
953async 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    // Extract sessionId
974    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    // ACP-S1-13a: non-existent session ID → -32000 error
989    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    // Load transcript from disk
1001    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    // Load metadata from disk
1016    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    // ACP-S1-13d: handle mcpServers override — gracefully skip invalid servers
1031    // (log warning, still return a valid context).
1032    let mcp_servers = params
1033        .get("mcpServers")
1034        .cloned()
1035        .or(metadata.mcp_servers.clone());
1036
1037    // Build messages from transcript
1038    let messages: Vec<crate::message::Message> =
1039        saved_messages.iter().map(|sm| sm.message.clone()).collect();
1040
1041    // ACP-S1-05: compute compaction hints (default recency threshold = 2)
1042    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    // Build fresh AgentRuntime with a new bridge
1054    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    // ACP-S1-16: wrap cancel token in Arc
1083    let cancel_token = Arc::new(tokio_util::sync::CancellationToken::new());
1084
1085    // S2-E14: kill old MCP child processes before replacing the session
1086    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    // ACP-S1-03: return context — NO session/update notifications emitted.
1107    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
1118// ---------------------------------------------------------------------------
1119// session/prompt handler
1120// ---------------------------------------------------------------------------
1121
1122/// Handle `session/prompt`: concatenate ContentBlock[] text, feed to agent,
1123/// collect bridge notifications.
1124async 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    // Extract sessionId
1144    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    // Look up the session
1159    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    // Extract and parse ContentBlock[] → concatenate text
1174    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 there were non-text blocks but text is also present, proceed
1220    if concatenated.is_empty() && !non_text_blocks.is_empty() {
1221        // All blocks were non-text; return a friendly response
1222        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    // Create a new bridge for this turn and swap it in.
1276    // Sprint 2: turn counter passed for turnId; tool_call notifications disabled.
1277    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    // Sprint 4: refresh the cancel token for this turn.
1283    // This creates a fresh CancellationToken wired to the agent runtime
1284    // via set_interrupt_token, ensuring a cancel only affects the
1285    // currently in-flight turn.
1286    let _ = session.refresh_cancel_token();
1287
1288    // AC-2.6: Emit user_message_chunk before running the agent.
1289    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    // Run the agent
1307    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    // Increment turn counter
1322    session.turn += 1;
1323
1324    // Drain all notifications from the bridge
1325    let mut notifications: Vec<Value> = vec![user_notif];
1326    while let Ok(notif) = rx.try_recv() {
1327        notifications.push(notif);
1328    }
1329
1330    // AC-2.5: Complete stopReason mapping per contract.
1331    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
1349// ---------------------------------------------------------------------------
1350// session/cancel handler (Sprint 4)
1351// ---------------------------------------------------------------------------
1352
1353/// Handle `session/cancel`: fire the session's `CancellationToken`.
1354///
1355/// Per S1-C12 contract:
1356/// - Request format: `{"method":"session/cancel","params":{}}` OR
1357///   `{"method":"session/cancel","params":{"sessionId":"..."}}` for
1358///   targeting a specific session.
1359/// - Success response: `{"result":true}`.
1360/// - Non-existent or non-running session → JSON-RPC error -32000.
1361///
1362/// When params is empty (`{}`), the handler cancels the first running
1363/// session (the one most recently prompted). If no session exists or
1364/// none is running, returns error -32000.
1365fn handle_session_cancel(
1366    id: &Value,
1367    params: Option<&Value>,
1368    sessions: &mut AcpSessionManager,
1369) -> (Option<Value>, Vec<Value>) {
1370    // Determine which session to cancel.
1371    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        // Target a specific session by ID.
1379        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        // Cancel — fire the token (idempotent: double-cancel is safe).
1394        session.cancel_token.cancel();
1395
1396        (Some(build_success(id, Value::Bool(true))), vec![])
1397    } else {
1398        // Empty params — try to cancel the most recently active session.
1399        // Use the session with the highest turn counter as proxy for "most recently active".
1400        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                        // Race: another task just removed the session between
1415                        // all_turns() and get_mut(). Treat as no-op success
1416                        // since the cancellation target is gone.
1417                        (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
1433// ---------------------------------------------------------------------------
1434// session/close handler (S2-E11 / S2-E12)
1435// ---------------------------------------------------------------------------
1436
1437/// Handle `session/close`: tear down a session, killing all MCP child
1438/// processes and releasing resources.
1439///
1440/// Per S2-E11:
1441/// - Request: `{"method":"session/close","params":{"sessionId":"..."}}`
1442/// - Success: `{"result":true}`
1443/// - Non-existent session → JSON-RPC error -32000.
1444///
1445/// Before removing the session, kills all tracked stdio MCP subprocesses
1446/// using the kill-gracefully protocol (SIGTERM → timeout → SIGKILL).
1447/// Only affects the targeted session's child processes (S2-E12).
1448async 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    // Check if session exists
1468    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    // Kill MCP child processes before removing
1480    if let Some(session) = sessions.get_mut(&sid) {
1481        session.kill_mcp_children().await;
1482    }
1483
1484    // Remove the session
1485    sessions.remove(&sid);
1486
1487    tracing::info!(session = %sid, "session/close: session closed");
1488    (Some(build_success(id, Value::Bool(true))), vec![])
1489}
1490
1491// ---------------------------------------------------------------------------
1492// AcpServer — stdio transport loop
1493// ---------------------------------------------------------------------------
1494
1495/// ACP stdio JSON-RPC server.
1496pub struct AcpServer;
1497
1498impl AcpServer {
1499    /// Run the server on real stdio. Implements 60-second idle timeout (S1-C9):
1500    /// if no input arrives on stdin for 60 seconds, shuts down cleanly with exit
1501    /// code 0 and logs "ServerShutdown" to stderr. EOF (e.g. /dev/null) causes
1502    /// a brief sleep then retry — the server never exits on EOF alone.
1503    ///
1504    /// `llm` is the shared chat provider used to construct AgentRuntimes for
1505    /// new sessions. Pass `None` only when you only need `initialize` support
1506    /// (e.g. smoke tests, idle-timeout tests).
1507    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    /// Run the server on real stdio with 60-second idle timeout.
1514    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            // S1-C9: 60-second idle timeout on stdin read.
1526            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                    // EOF — for /dev/null this happens immediately.
1533                    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                    // S1-C9: 60-second idle timeout
1542                    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    /// Dispatch a single input line (single request/notification or batch).
1563    /// Shared by `run_stdio` and `run_io`.
1564    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        // Parse the line as a JSON value
1578        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            // Batch
1589            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            // Single request/notification
1617            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 &notifications {
1623                let _ = write_line(writer, notif).await;
1624            }
1625        }
1626    }
1627
1628    /// Dispatch a single JSON-RPC item (from batch or single request).
1629    /// Returns (response, notifications).
1630    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        // Try to parse as JsonRpcEnvelope; on failure, return parse error
1638        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        // Validate jsonrpc field
1654        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        // Get method name
1681        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        // Notifications (no id): silently discard
1697        let id = match &env.id {
1698            Some(id) => id.clone(),
1699            None => return (None, vec![]),
1700        };
1701
1702        // Check synchronous dispatch table (HashMap) first (S1-C11)
1703        if let Some(handler) = sync_table.get(method) {
1704            let resp = handler(&id, env.params.as_ref());
1705
1706            // State management for initialize
1707            // S2-E1/E3: extract client capabilities from initialize params and store
1708            // on the session manager for use by session/new.
1709            if resp.get("result").is_some() && method == "initialize" {
1710                match *state {
1711                    ServerState::Uninitialized => {
1712                        *state = ServerState::Initialized;
1713
1714                        // Parse client capabilities from initialize params (S2-E1/E3)
1715                        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                        // Duplicate initialize → error
1736                        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        // Fall through to async dispatch (session methods)
1752        dispatch_async(method, &id, env.params.as_ref(), state, sessions, llm).await
1753    }
1754
1755    /// Run the server on generic reader/writer (testable). Exits on EOF.
1756    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// ---------------------------------------------------------------------------
1789// Tests
1790// ---------------------------------------------------------------------------
1791
1792#[cfg(test)]
1793mod tests {
1794    use super::*;
1795    use crate::llm::MockProvider;
1796
1797    // ── helpers ──────────────────────────────────────────────────────
1798
1799    /// JSON-encode a filesystem path for embedding in test request strings.
1800    /// Windows paths contain backslashes that must be escaped as JSON string content.
1801    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    /// Create a MockProvider that returns a single "Hello" completion.
1806    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    /// Create a MockProvider that returns completions for multi-turn.
1814    fn mock_llm_multi(completions: Vec<crate::llm::Completion>) -> Arc<dyn ChatProvider> {
1815        Arc::new(MockProvider::new(completions))
1816    }
1817
1818    /// Run the server on a string input, return captured stdout lines.
1819    /// Uses Some(llm) so session methods work (backward compat).
1820    async fn run_server(input: &str) -> Vec<String> {
1821        run_server_with_llm(input, Some(mock_llm())).await
1822    }
1823
1824    /// Run the server with no LLM provider (Sprint-1-only mode).
1825    #[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    /// Helper: parse stdout line at index as JSON Value.
1839    fn parse_line(lines: &[String], idx: usize) -> Value {
1840        serde_json::from_str(&lines[idx]).expect("valid JSON")
1841    }
1842
1843    /// Collect all lines matching `session/update` method.
1844    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    // ══════════════════════════════════════════════════════════════════
1859    // C1 — initialize returns success response
1860    // ══════════════════════════════════════════════════════════════════
1861
1862    #[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    // ══════════════════════════════════════════════════════════════════
1883    // C1 — protocolVersion, agentInfo, agentCapabilities
1884    // ══════════════════════════════════════════════════════════════════
1885
1886    #[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    /// AC02: agentCapabilities uses the contract-required flat keys (S1-C2).
1900    #[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        // S1-C2: 8 contract-required keys, terminalCapabilities is explicitly false
1910        assert_eq!(caps["terminalCapabilities"], false);
1911        // The remaining 7 must be truthy (non-null, non-false)
1912        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        // S1-C1: at least 5 keys total
1929        assert!(caps.as_object().map_or(0, |o| o.len()) >= 5);
1930    }
1931
1932    // ══════════════════════════════════════════════════════════════════
1933    // C5 — malformed JSON → ParseError (-32700), then continue
1934    // ══════════════════════════════════════════════════════════════════
1935
1936    #[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    /// S1-C5: `{bad json` (no closing brace) must produce parse error, not crash.
1947    #[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    /// S1-C5: after parse error, process must still accept valid requests.
1960    #[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    // ══════════════════════════════════════════════════════════════════
1998    // C8 — missing jsonrpc/method → Invalid Request (-32600)
1999    // ══════════════════════════════════════════════════════════════════
2000
2001    #[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    // ══════════════════════════════════════════════════════════════════
2021    // C6 — MethodNotFound (-32601) for unknown methods
2022    // ══════════════════════════════════════════════════════════════════
2023
2024    /// S1-C6: unknown method BEFORE initialize also returns METHOD_NOT_FOUND (-32601),
2025    /// not SERVER_NOT_INITIALIZED. The server must not require a preceding initialize
2026    /// to report that a method doesn't exist.
2027    #[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    /// S1-C6: after unknown-method error, server stays alive and accepts valid requests.
2040    #[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    // ══════════════════════════════════════════════════════════════════
2076    // C7 — notifications → no response
2077    // ══════════════════════════════════════════════════════════════════
2078
2079    #[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    // ══════════════════════════════════════════════════════════════════
2105    // C2 — initialized notification ordering
2106    // ══════════════════════════════════════════════════════════════════
2107
2108    #[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    // ══════════════════════════════════════════════════════════════════
2131    // C3 — multi-request FIFO ordering
2132    // ══════════════════════════════════════════════════════════════════
2133
2134    #[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    /// S1-C3: Pipe 3 sequential initialize requests with ids 1, 2, 3.
2158    /// Each must produce a valid response with matching id and `.jsonrpc == "2.0"`.
2159    /// The first succeeds; the 2nd and 3rd return "already initialized" errors.
2160    #[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        // Expected: init response(1) + initialized notif + error(2) + error(3) = 4 lines
2171        assert_eq!(lines.len(), 4, "init response + initialized + 2 errors");
2172
2173        // Line 0: successful initialize with id 1
2174        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        // Line 1: initialized notification
2180        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        // Line 2: already initialized error with id 2
2185        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        // Line 3: already initialized error with id 3
2191        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    /// S1-C4: Every stdout line must be valid JSON with `.jsonrpc == "2.0"`.
2198    #[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    // ══════════════════════════════════════════════════════════════════
2221    // State machine tests
2222    // ══════════════════════════════════════════════════════════════════
2223
2224    #[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    // ══════════════════════════════════════════════════════════════════
2252    // Invalid params tests
2253    // ══════════════════════════════════════════════════════════════════
2254
2255    #[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    // ══════════════════════════════════════════════════════════════════
2285    // S1-C1, S1-C2: response has protocolVersion, agentInfo, and contract agentCapabilities
2286    // ══════════════════════════════════════════════════════════════════
2287
2288    #[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        // S1-C2: 8 contract-required forward-capability keys
2302        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        // terminalCapabilities is explicitly false
2310        assert_eq!(caps["terminalCapabilities"], false);
2311    }
2312
2313    // ══════════════════════════════════════════════════════════════════
2314    // Sprint 4: session/cancel tests (C12, C14)
2315    // ══════════════════════════════════════════════════════════════════
2316
2317    #[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        // init response + initialized + session/new + session/cancel
2329        assert!(lines.len() >= 4, "expected ≥4 lines, got {}", lines.len());
2330        let v = parse_line(&lines, 3);
2331        // S1-C12: success response is result:true (not {cancelled:true})
2332        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    // ══════════════════════════════════════════════════════════════════
2377    // Sprint 4: CancellationToken lifecycle (C1)
2378    // ══════════════════════════════════════════════════════════════════
2379
2380    #[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        // Create session, run two turns with the same server instance.
2397        // After the second turn, verify the session's cancel token is
2398        // fresh (not cancelled).
2399        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        // Use a separate session manager to inspect state
2408        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 &notifs {
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        // After the second turn (session/prompt), the session should have
2446        // a fresh CancellationToken that is NOT cancelled.
2447        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    // ══════════════════════════════════════════════════════════════════
2455    // Batch tests
2456    // ══════════════════════════════════════════════════════════════════
2457
2458    #[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    // ══════════════════════════════════════════════════════════════════
2529    // Robustness tests
2530    // ══════════════════════════════════════════════════════════════════
2531
2532    #[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    // ══════════════════════════════════════════════════════════════════
2590    // S1-C9 — 60-second idle timeout
2591    // ══════════════════════════════════════════════════════════════════
2592
2593    /// S1-C9: After 60s of no input, the server exits cleanly.
2594    ///
2595    /// Uses `tokio::time::pause()` to freeze time and then `advance()`
2596    /// to jump 61 seconds forward, triggering the idle timeout in
2597    /// `run_stdio`. The function must return without panicking (exit
2598    /// code 0 equivalent) and produce no stdout output on timeout.
2599    #[tokio::test]
2600    async fn idle_timeout_shuts_down_server_after_60s() {
2601        tokio::time::pause();
2602
2603        // Create a duplex pair. Keep the writer alive so the reader
2604        // doesn't see EOF — it will block waiting for a line that
2605        // never arrives. This simulates stdin with no input.
2606        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        // Let the spawned task start so it reaches the timeout call.
2616        tokio::task::yield_now().await;
2617
2618        // Advance time past the 60-second idle timeout.
2619        tokio::time::advance(std::time::Duration::from_secs(61)).await;
2620
2621        // The server task must complete within 500ms of real time.
2622        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    /// S1-C9: Confirms that the timeout only fires after 60s of idle,
2640    /// not earlier. Advance 30s, verify server is still running.
2641    #[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        // Advance only 30 seconds — not enough to trigger the 60s timeout.
2657        tokio::time::advance(std::time::Duration::from_secs(30)).await;
2658
2659        // The task should still be running.
2660        assert!(!handle.is_finished(), "server should still be alive at 30s");
2661
2662        // Now advance past 60s total — timeout should fire.
2663        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    // ══════════════════════════════════════════════════════════════════
2673    // Sprint 2: session/new tests
2674    // ══════════════════════════════════════════════════════════════════
2675
2676    #[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    // ══════════════════════════════════════════════════════════════════
2726    // Sprint 2: session/prompt tests
2727    // ══════════════════════════════════════════════════════════════════
2728
2729    #[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        // Phase 1: create session, get ID
2751        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        // Phase 2: use the sessionId for session/prompt
2768        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        // Find the session/prompt response (last line with "id":3)
2790        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        // Find session/update notifications
2798        let notifs = find_notifications(&lines2);
2799        assert!(!notifs.is_empty(), "expected session/update notifications");
2800
2801        // At least one should have agent_message_chunk with text
2802        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        // Last notification should have stopReason
2813        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        // 3 requests → 3 responses (lines[0..3]). session/prompt is the 3rd
2879        // request, so its error response is at idx 3.
2880        let v = parse_line(&lines, 3);
2881        assert_eq!(v["error"]["code"], INVALID_PARAMS);
2882    }
2883
2884    // ══════════════════════════════════════════════════════════════════
2885    // Sprint 2: multi-turn context
2886    // ══════════════════════════════════════════════════════════════════
2887
2888    #[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        // Phase 1: create session
2905        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        // Phase 2: run two turns with the same session
2926        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    // ══════════════════════════════════════════════════════════════════
2944    // Sprint 2 contract evaluation — AC-2.1 through AC-2.10
2945    // ══════════════════════════════════════════════════════════════════
2946
2947    /// AC-2.1: session/new path traversal sandbox check.
2948    #[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    /// AC-2.1: session/new returns stable id valid for session/prompt.
2973    #[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    /// AC-2.1 (supplemental): nonexistent cwd returns error.
2995    #[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    /// AC-2.2: session/prompt streams agent_message_chunk + end_turn.
3013    #[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    /// AC-2.2: end_turn is the last notification per turn.
3043    #[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    /// AC-2.3: mixed text + non-text — server does not crash, processes text only.
3064    #[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    /// AC-2.3 (c): only non-text blocks → graceful response, no crash.
3083    #[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    /// AC-2.4: Multi-turn context preservation.
3111    #[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    /// AC-2.5: stopReason = end_turn for normal completion.
3153    #[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    /// AC-2.6: messageId is deterministic 12-char lowercase hex across sessions.
3175    #[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    /// AC-2.7: EventSink emits only user_message_chunk + agent_message_chunk + end_turn.
3235    #[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 &notifs {
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 &notifs {
3273            assert_eq!(n["jsonrpc"], "2.0");
3274            assert_eq!(n["method"], "session/update");
3275        }
3276    }
3277
3278    /// AC-2.9: stale sessionId → JSON-RPC error, server stays alive.
3279    #[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    /// AC-2.10: empty prompt array returns error, no crash/hang/malformed JSON.
3308    #[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}