Skip to main content

agent_sdk_toolkit/testing/protocol/
acp.rs

1//! Scripted ACP protocol harnesses for SDK consumers. Use these fakes in tests to
2//! prove lifecycle and message exchange without a live editor or product host.
3//! Harness methods mutate in-memory endpoint transcripts only.
4//!
5use std::collections::{BTreeMap, BTreeSet};
6
7use agent_sdk_core::AgentError;
8use serde_json::{Value, json};
9
10use crate::protocol::{
11    JsonRpcFrame, JsonRpcId, JsonRpcLineEndpoint, JsonRpcNotification, JsonRpcRequest,
12    JsonRpcResponse, expect_notification, expect_response, protocol_violation,
13};
14
15enum ReceiveNext {
16    Empty,
17    Handled,
18    Frame(JsonRpcFrame),
19}
20
21#[derive(Clone, Debug)]
22/// In-memory scripted acp client fixture for SDK conformance tests.
23/// Use it to script deterministic behavior in memory; any transcript or endpoint mutation is documented on the method that performs it.
24pub struct ScriptedAcpClient {
25    endpoint: JsonRpcLineEndpoint,
26    next_id: i64,
27}
28
29impl ScriptedAcpClient {
30    /// Creates a new testing::protocol::acp value with explicit
31    /// caller-provided inputs. This constructor is data-only and
32    /// performs no I/O or external side effects.
33    pub fn new(endpoint: JsonRpcLineEndpoint) -> Self {
34        Self {
35            endpoint,
36            next_id: 1,
37        }
38    }
39
40    /// Returns the endpoint currently held by this value.
41    /// This reads scripted protocol state or a queued response without contacting an external
42    /// process.
43    pub fn endpoint(&self) -> &JsonRpcLineEndpoint {
44        &self.endpoint
45    }
46
47    /// Initialize.
48    /// This appends the corresponding JSON-RPC frame to the scripted ACP mock transcript and
49    /// returns the request id when applicable.
50    pub fn initialize(&mut self) -> Result<JsonRpcId, AgentError> {
51        self.request(
52            "initialize",
53            json!({
54                "protocolVersion": 1,
55                "clientInfo": {
56                    "name": "agent-sdk-toolkit-acp-fake",
57                    "version": "0.0.0"
58                },
59                "clientCapabilities": {
60                    "fs": {"readTextFile": false, "writeTextFile": false},
61                    "terminal": false
62                }
63            }),
64        )
65    }
66
67    /// Queues an ACP `session/new` request in the scripted client transcript.
68    /// This mutates only the in-memory JSON-RPC endpoint and returns the request id; no live ACP
69    /// server, journal, event bus, or process is contacted.
70    pub fn new_session(&mut self, cwd: impl Into<String>) -> Result<JsonRpcId, AgentError> {
71        self.request(
72            "session/new",
73            json!({
74                "cwd": cwd.into(),
75                "mcpServers": []
76            }),
77        )
78    }
79
80    /// Prompt.
81    /// This appends the corresponding JSON-RPC frame to the in-memory test endpoint transcript.
82    pub fn prompt(
83        &mut self,
84        session_id: impl Into<String>,
85        prompt: impl Into<String>,
86    ) -> Result<JsonRpcId, AgentError> {
87        self.request(
88            "session/prompt",
89            json!({
90                "sessionId": session_id.into(),
91                "prompt": [{"type": "text", "text": prompt.into()}]
92            }),
93        )
94    }
95
96    /// Cancel.
97    /// This appends the corresponding JSON-RPC frame to the scripted ACP mock transcript and
98    /// returns the request id when applicable.
99    pub fn cancel(&self, session_id: impl Into<String>) -> Result<(), AgentError> {
100        self.endpoint
101            .send_notification("session/cancel", json!({"sessionId": session_id.into()}))
102            .map(|_| ())
103    }
104
105    /// Handle next.
106    /// This consumes one queued JSON-RPC frame from the in-memory endpoint and mutates only
107    /// scripted mock state.
108    pub fn handle_next(&mut self, endpoint: &JsonRpcLineEndpoint) -> Result<bool, AgentError> {
109        let frame = match receive_frame_or_parse_error(endpoint)? {
110            ReceiveNext::Empty => return Ok(false),
111            ReceiveNext::Handled => return Ok(true),
112            ReceiveNext::Frame(frame) => frame,
113        };
114        let JsonRpcFrame::Request(request) = frame else {
115            return Ok(true);
116        };
117        match request.method.as_str() {
118            "fs/read_text_file" => {
119                endpoint.send_error(
120                    Some(request.id),
121                    -32003,
122                    "ACP fs/read_text_file denied by client capability policy",
123                )?;
124            }
125            "terminal/create" => {
126                endpoint.send_error(
127                    Some(request.id),
128                    -32004,
129                    "ACP terminal/create denied by client capability policy",
130                )?;
131            }
132            "session/request_permission" => {
133                endpoint.send_result(request.id, json!({"outcome": {"outcome": "cancelled"}}))?;
134            }
135            _ => {
136                endpoint.send_error(Some(request.id), -32601, "ACP client method not found")?;
137            }
138        }
139        Ok(true)
140    }
141
142    /// Returns the response currently held by this value.
143    /// This reads scripted protocol state or a queued response without contacting an external
144    /// process.
145    pub fn response(&self) -> Result<JsonRpcResponse, AgentError> {
146        expect_response(self.endpoint.receive_frame()?)
147    }
148
149    /// Returns notification for this testing::protocol::acp value without
150    /// performing external I/O.
151    pub fn notification(&self) -> Result<JsonRpcNotification, AgentError> {
152        expect_notification(self.endpoint.receive_frame()?)
153    }
154
155    fn request(&mut self, method: &str, params: Value) -> Result<JsonRpcId, AgentError> {
156        let id = JsonRpcId::Number(self.next_id);
157        self.next_id += 1;
158        self.endpoint
159            .send_request(id.clone(), method, params)
160            .map(|_| id)
161    }
162}
163
164#[derive(Clone, Debug)]
165/// In-memory scripted acp agent fixture for SDK conformance tests.
166/// Use it to script deterministic behavior in memory; any transcript or endpoint mutation is documented on the method that performs it.
167pub struct ScriptedAcpAgent {
168    session_id: String,
169    prompt_outputs: BTreeMap<String, String>,
170    cancelled_sessions: BTreeSet<String>,
171    next_client_request: i64,
172}
173
174impl ScriptedAcpAgent {
175    /// Creates a new testing::protocol::acp value with explicit
176    /// caller-provided inputs. This constructor is data-only and
177    /// performs no I/O or external side effects.
178    pub fn new(session_id: impl Into<String>) -> Self {
179        Self {
180            session_id: session_id.into(),
181            prompt_outputs: BTreeMap::new(),
182            cancelled_sessions: BTreeSet::new(),
183            next_client_request: 1,
184        }
185    }
186
187    /// Returns this value with its prompt output setting replaced. The
188    /// method follows builder-style data construction and does not
189    /// execute external work.
190    pub fn with_prompt_output(
191        mut self,
192        prompt: impl Into<String>,
193        output: impl Into<String>,
194    ) -> Self {
195        self.prompt_outputs.insert(prompt.into(), output.into());
196        self
197    }
198
199    /// Handle next.
200    /// This consumes one queued JSON-RPC frame from the in-memory endpoint and mutates only
201    /// scripted mock state.
202    pub fn handle_next(&mut self, endpoint: &JsonRpcLineEndpoint) -> Result<bool, AgentError> {
203        let frame = match receive_frame_or_parse_error(endpoint)? {
204            ReceiveNext::Empty => return Ok(false),
205            ReceiveNext::Handled => return Ok(true),
206            ReceiveNext::Frame(frame) => frame,
207        };
208        let request = match frame {
209            JsonRpcFrame::Request(request) => request,
210            JsonRpcFrame::Notification(notification) => {
211                if notification.method == "session/cancel" {
212                    if let Some(session_id) =
213                        notification.params.get("sessionId").and_then(Value::as_str)
214                    {
215                        self.cancelled_sessions.insert(session_id.to_string());
216                    }
217                    return Ok(true);
218                }
219                endpoint.send_error(
220                    None,
221                    -32600,
222                    "ACP agent expected a request or known notification",
223                )?;
224                return Ok(true);
225            }
226            JsonRpcFrame::Response(_) => {
227                endpoint.send_error(None, -32600, "ACP agent expected a request frame")?;
228                return Ok(true);
229            }
230        };
231        match request.method.as_str() {
232            "initialize" => {
233                endpoint.send_result(
234                    request.id,
235                    json!({
236                        "protocolVersion": request.params
237                            .get("protocolVersion")
238                            .cloned()
239                            .unwrap_or_else(|| json!(1)),
240                        "agentCapabilities": {
241                            "auth": {},
242                            "loadSession": false,
243                            "mcpCapabilities": {"http": false, "sse": false},
244                            "promptCapabilities": {
245                                "audio": false,
246                                "embeddedContext": false,
247                                "image": false
248                            },
249                            "sessionCapabilities": {}
250                        },
251                        "agentInfo": {
252                            "name": "agent-sdk-toolkit-acp-fake",
253                            "version": "0.0.0"
254                        },
255                        "authMethods": []
256                    }),
257                )?;
258            }
259            "session/new" => {
260                endpoint.send_result(
261                    request.id,
262                    json!({
263                        "sessionId": self.session_id,
264                        "modes": null,
265                        "configOptions": null
266                    }),
267                )?;
268            }
269            "session/prompt" => self.handle_prompt(endpoint, request)?,
270            _ => {
271                endpoint.send_error(Some(request.id), -32601, "ACP method not found")?;
272            }
273        };
274        Ok(true)
275    }
276
277    /// Request file read.
278    /// This appends the corresponding JSON-RPC frame to the in-memory test endpoint transcript.
279    pub fn request_file_read(
280        &mut self,
281        endpoint: &JsonRpcLineEndpoint,
282        session_id: impl Into<String>,
283        path: impl Into<String>,
284    ) -> Result<JsonRpcId, AgentError> {
285        let id = self.next_client_request_id();
286        endpoint
287            .send_request(
288                id.clone(),
289                "fs/read_text_file",
290                json!({"sessionId": session_id.into(), "path": path.into()}),
291            )
292            .map(|_| id)
293    }
294
295    /// Request terminal create.
296    /// This appends the corresponding JSON-RPC frame to the in-memory test endpoint transcript.
297    pub fn request_terminal_create(
298        &mut self,
299        endpoint: &JsonRpcLineEndpoint,
300        session_id: impl Into<String>,
301        command: impl Into<String>,
302    ) -> Result<JsonRpcId, AgentError> {
303        let id = self.next_client_request_id();
304        endpoint
305            .send_request(
306                id.clone(),
307                "terminal/create",
308                json!({
309                    "sessionId": session_id.into(),
310                    "command": command.into(),
311                    "args": [],
312                    "cwd": null
313                }),
314            )
315            .map(|_| id)
316    }
317
318    /// Request permission.
319    /// This appends the corresponding JSON-RPC frame to the in-memory test endpoint transcript.
320    pub fn request_permission(
321        &mut self,
322        endpoint: &JsonRpcLineEndpoint,
323        session_id: impl Into<String>,
324        tool_call_id: impl Into<String>,
325    ) -> Result<JsonRpcId, AgentError> {
326        let id = self.next_client_request_id();
327        endpoint
328            .send_request(
329                id.clone(),
330                "session/request_permission",
331                json!({
332                    "sessionId": session_id.into(),
333                    "toolCall": {"toolCallId": tool_call_id.into()},
334                    "options": [
335                        {"optionId": "allow-once", "name": "Allow once", "kind": "allow_once"},
336                        {"optionId": "reject-once", "name": "Reject", "kind": "reject_once"}
337                    ]
338                }),
339            )
340            .map(|_| id)
341    }
342
343    /// Returns the response currently held by this value.
344    /// This reads scripted protocol state or a queued response without contacting an external
345    /// process.
346    pub fn response(&self, endpoint: &JsonRpcLineEndpoint) -> Result<JsonRpcResponse, AgentError> {
347        expect_response(endpoint.receive_frame()?)
348    }
349
350    /// Cancelled sessions.
351    /// This reads the scripted ACP mock cancellation set without sending a frame.
352    pub fn cancelled_sessions(&self) -> BTreeSet<String> {
353        self.cancelled_sessions.clone()
354    }
355
356    fn next_client_request_id(&mut self) -> JsonRpcId {
357        let id = JsonRpcId::Number(self.next_client_request);
358        self.next_client_request += 1;
359        id
360    }
361
362    fn handle_prompt(
363        &mut self,
364        endpoint: &JsonRpcLineEndpoint,
365        request: JsonRpcRequest,
366    ) -> Result<(), AgentError> {
367        let prompt = request
368            .params
369            .get("prompt")
370            .and_then(Value::as_array)
371            .and_then(|blocks| blocks.first())
372            .and_then(|block| block.get("text"))
373            .and_then(Value::as_str)
374            .ok_or_else(|| protocol_violation("ACP prompt request requires prompt string"))?;
375        let session_id = request
376            .params
377            .get("sessionId")
378            .and_then(Value::as_str)
379            .ok_or_else(|| protocol_violation("ACP prompt request requires sessionId"))?;
380        if session_id != self.session_id {
381            endpoint.send_error(Some(request.id), -32602, "ACP session is not active")?;
382            return Ok(());
383        }
384        let output = self
385            .prompt_outputs
386            .get(prompt)
387            .cloned()
388            .unwrap_or_else(|| "scripted ACP response".to_string());
389        endpoint.send_notification(
390            "session/update",
391            json!({
392                "sessionId": session_id,
393                "update": {
394                    "sessionUpdate": "agent_message_chunk",
395                    "content": {
396                        "type": "text",
397                        "text": output
398                    }
399                }
400            }),
401        )?;
402        endpoint.send_result(request.id, json!({"stopReason": "end_turn"}))?;
403        Ok(())
404    }
405}
406
407fn receive_frame_or_parse_error(endpoint: &JsonRpcLineEndpoint) -> Result<ReceiveNext, AgentError> {
408    let Some(line) = endpoint.try_receive_raw_line()? else {
409        return Ok(ReceiveNext::Empty);
410    };
411    match JsonRpcFrame::from_line(&line) {
412        Ok(frame) => Ok(ReceiveNext::Frame(frame)),
413        Err(error) => {
414            endpoint.send_error(None, -32700, error.context().message)?;
415            Ok(ReceiveNext::Handled)
416        }
417    }
418}