agent_sdk_toolkit/testing/protocol/
acp.rs1use 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)]
22pub struct ScriptedAcpClient {
25 endpoint: JsonRpcLineEndpoint,
26 next_id: i64,
27}
28
29impl ScriptedAcpClient {
30 pub fn new(endpoint: JsonRpcLineEndpoint) -> Self {
34 Self {
35 endpoint,
36 next_id: 1,
37 }
38 }
39
40 pub fn endpoint(&self) -> &JsonRpcLineEndpoint {
44 &self.endpoint
45 }
46
47 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 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 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 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 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 pub fn response(&self) -> Result<JsonRpcResponse, AgentError> {
146 expect_response(self.endpoint.receive_frame()?)
147 }
148
149 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)]
165pub 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 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 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 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 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 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 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 pub fn response(&self, endpoint: &JsonRpcLineEndpoint) -> Result<JsonRpcResponse, AgentError> {
347 expect_response(endpoint.receive_frame()?)
348 }
349
350 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}