1use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15
16use serde_json::{json, Value};
17use termesh_core::{
18 AgentCapabilities, AgentEvent, AgentRequest, AgentTerminalOperation, AgentTerminalRequestId,
19 AgentTerminalResponse, PermissionDecision, PermissionRequestId, PromptCapabilities, ProposalId,
20 ReadRequestId, SessionId, SessionMode, StopReason, TerminalExit, TerminalId, TerminalSpec,
21};
22
23use crate::jsonrpc::{Message, RequestIds};
24use crate::service::ClientCapabilities;
25
26const PROTOCOL_VERSION: u64 = 1;
28const DEFAULT_OUTPUT_LIMIT: usize = 1_048_576;
29const MAX_OUTPUT_LIMIT: usize = 8_388_608;
30
31#[derive(Debug, Clone, PartialEq, Eq)]
33enum Pending {
34 Initialize,
35 NewSession,
36 Prompt(SessionId),
37 SetMode { session: SessionId, mode: String },
38}
39
40#[derive(Debug, Default)]
42pub struct Translator {
43 ids: RequestIds,
44 pending: HashMap<u64, Pending>,
45 sessions: Vec<(SessionId, String)>,
48 permissions: HashMap<PermissionRequestId, PendingPermission>,
50 reads: HashMap<ReadRequestId, u64>,
53 terminal_rpcs: HashMap<AgentTerminalRequestId, PendingTerminalRpc>,
54 terminals: Vec<TerminalBinding>,
55 one_shot_terminal_grants: Vec<(SessionId, TerminalSpec)>,
56 next_session: u64,
57 next_id: u64,
58 ready: bool,
60 queued: Vec<AgentRequest>,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq)]
65struct PermissionOption {
66 id: String,
67 kind: String,
68}
69
70#[derive(Debug, Clone)]
71struct PendingPermission {
72 wire_request: u64,
73 session: SessionId,
74 options: Vec<PermissionOption>,
75 terminal_spec: Option<TerminalSpec>,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79enum TerminalRpcKind {
80 Create,
81 Output(TerminalId),
82 Wait(TerminalId),
83 Kill(TerminalId),
84 Release(TerminalId),
85}
86
87#[derive(Debug, Clone, Copy)]
88struct PendingTerminalRpc {
89 wire_request: u64,
90 session: SessionId,
91 kind: TerminalRpcKind,
92}
93
94#[derive(Debug, Clone)]
95struct TerminalBinding {
96 local: TerminalId,
97 wire: String,
98 session: SessionId,
99 released: bool,
100}
101
102impl Translator {
103 pub fn new() -> Self {
104 Self::default()
105 }
106
107 pub fn initialize(&mut self, capabilities: ClientCapabilities) -> Message {
109 let id = self.ids.allocate();
110 self.pending.insert(id, Pending::Initialize);
111 Message::Request {
112 id,
113 method: "initialize".into(),
114 params: json!({
115 "protocolVersion": PROTOCOL_VERSION,
116 "clientCapabilities": {
117 "fs": {
118 "readTextFile": capabilities.read_text_file,
119 "writeTextFile": capabilities.write_text_file,
120 },
121 "terminal": capabilities.terminal,
122 }
123 }),
124 }
125 }
126
127 fn wire_session(&self, session: SessionId) -> Option<&str> {
128 self.sessions.iter().find(|(ours, _)| *ours == session).map(|(_, wire)| wire.as_str())
129 }
130
131 fn our_session(&self, wire: &str) -> Option<SessionId> {
132 self.sessions.iter().find(|(_, theirs)| theirs == wire).map(|(ours, _)| *ours)
133 }
134
135 fn terminal_binding(&self, wire: &str) -> Option<&TerminalBinding> {
136 self.terminals.iter().find(|binding| binding.wire == wire)
137 }
138
139 fn live_terminal(&self, wire: &str, session: SessionId) -> Option<TerminalId> {
140 self.terminal_binding(wire)
141 .filter(|binding| binding.session == session && !binding.released)
142 .map(|binding| binding.local)
143 }
144
145 fn allocate_terminal_request(
146 &mut self,
147 wire_request: u64,
148 session: SessionId,
149 kind: TerminalRpcKind,
150 operation: AgentTerminalOperation,
151 ) -> AgentEvent {
152 self.next_id += 1;
153 let request = AgentTerminalRequestId::new(self.next_id);
154 self.terminal_rpcs.insert(request, PendingTerminalRpc { wire_request, session, kind });
155 AgentEvent::TerminalRequest { session, request, operation }
156 }
157
158 pub fn outgoing(&mut self, request: AgentRequest) -> Vec<Message> {
163 if !self.ready && !matches!(request, AgentRequest::Shutdown) {
164 self.queued.push(request);
165 return Vec::new();
166 }
167 self.encode(request).into_iter().collect()
168 }
169
170 fn encode(&mut self, request: AgentRequest) -> Option<Message> {
171 match request {
172 AgentRequest::NewSession { cwd } => {
173 let id = self.ids.allocate();
174 self.pending.insert(id, Pending::NewSession);
175 Some(Message::Request {
176 id,
177 method: "session/new".into(),
178 params: json!({ "cwd": cwd, "mcpServers": [] }),
181 })
182 }
183 AgentRequest::SetMode { session, mode } => {
184 let wire = self.wire_session(session)?.to_string();
185 let id = self.ids.allocate();
186 self.pending.insert(id, Pending::SetMode { session, mode: mode.clone() });
191 Some(Message::Request {
192 id,
193 method: "session/set_mode".into(),
194 params: json!({ "sessionId": wire, "modeId": mode }),
195 })
196 }
197 AgentRequest::Prompt { session, text, context } => {
198 let wire = self.wire_session(session)?.to_string();
199 let id = self.ids.allocate();
200 self.pending.insert(id, Pending::Prompt(session));
201 let blocks = if context.is_empty() {
204 vec![json!({ "type": "text", "text": text })]
205 } else {
206 vec![
207 json!({ "type": "text", "text": context }),
208 json!({ "type": "text", "text": text }),
209 ]
210 };
211 Some(Message::Request {
212 id,
213 method: "session/prompt".into(),
214 params: json!({ "sessionId": wire, "prompt": blocks }),
215 })
216 }
217 AgentRequest::FileContents { request, path, contents, .. } => {
218 let request_id = self.reads.remove(&request)?;
219 Some(match contents {
220 Some(content) => {
221 Message::Response { id: request_id, result: json!({ "content": content }) }
222 }
223 None => Message::Error {
226 id: request_id,
227 code: -32000,
228 message: format!("cannot read {}", path.display()),
229 },
230 })
231 }
232 AgentRequest::Permission { request, decision } => {
233 let pending = self.permissions.remove(&request)?;
234 let option = choose_option(&pending.options, decision);
235 if option.is_some() && decision.allows() {
236 if let Some(spec) = pending.terminal_spec {
237 self.one_shot_terminal_grants.push((pending.session, spec));
238 }
239 }
240 Some(match option {
241 Some(id) => Message::Response {
242 id: pending.wire_request,
243 result: json!({ "outcome": { "outcome": "selected", "optionId": id } }),
244 },
245 None => Message::Response {
248 id: pending.wire_request,
249 result: json!({ "outcome": { "outcome": "cancelled" } }),
250 },
251 })
252 }
253 AgentRequest::PermissionCancelled { request } => {
254 let pending = self.permissions.remove(&request)?;
255 Some(Message::Response {
256 id: pending.wire_request,
257 result: json!({ "outcome": { "outcome": "cancelled" } }),
258 })
259 }
260 AgentRequest::TerminalResponse { request, response } => {
261 self.encode_terminal_response(request, response)
262 }
263 AgentRequest::Cancel { session } => {
264 self.expire_terminal_grants(session);
265 let wire = self.wire_session(session)?.to_string();
266 Some(Message::Notification {
267 method: "session/cancel".into(),
268 params: json!({ "sessionId": wire }),
269 })
270 }
271 AgentRequest::Shutdown => None,
272 }
273 }
274
275 fn expire_terminal_grants(&mut self, session: SessionId) {
281 self.one_shot_terminal_grants.retain(|(owner, _)| *owner != session);
282 }
283
284 fn encode_terminal_response(
285 &mut self,
286 request: AgentTerminalRequestId,
287 response: AgentTerminalResponse,
288 ) -> Option<Message> {
289 let pending = self.terminal_rpcs.remove(&request)?;
290 if let AgentTerminalResponse::Error(message) = response {
291 return Some(Message::Error { id: pending.wire_request, code: -32000, message });
292 }
293
294 let result = match (pending.kind, response) {
295 (TerminalRpcKind::Create, AgentTerminalResponse::Created { terminal }) => {
296 if self.terminals.iter().any(|binding| binding.local == terminal) {
297 return Some(Message::Error {
298 id: pending.wire_request,
299 code: -32000,
300 message: format!("terminal {terminal} already has a wire id"),
301 });
302 }
303 let wire = format!("termesh-{}", terminal.0);
304 self.terminals.push(TerminalBinding {
305 local: terminal,
306 wire: wire.clone(),
307 session: pending.session,
308 released: false,
309 });
310 json!({ "terminalId": wire })
311 }
312 (
313 TerminalRpcKind::Output(_),
314 AgentTerminalResponse::Output { output, truncated, exit },
315 ) => json!({
316 "output": output,
317 "truncated": truncated,
318 "exitStatus": exit.map(exit_status),
319 }),
320 (TerminalRpcKind::Wait(_), AgentTerminalResponse::Exited(exit)) => exit_status(exit),
321 (TerminalRpcKind::Kill(_), AgentTerminalResponse::Acknowledged) => json!({}),
322 (TerminalRpcKind::Release(terminal), AgentTerminalResponse::Acknowledged) => {
323 if let Some(binding) = self
324 .terminals
325 .iter_mut()
326 .find(|binding| binding.local == terminal && binding.session == pending.session)
327 {
328 binding.released = true;
329 }
330 json!({})
331 }
332 (_, _) => {
333 return Some(Message::Error {
334 id: pending.wire_request,
335 code: -32000,
336 message: "terminal response did not match its request".into(),
337 });
338 }
339 };
340 Some(Message::Response { id: pending.wire_request, result })
341 }
342
343 pub fn incoming(&mut self, message: Message) -> (Vec<AgentEvent>, Vec<Message>) {
345 match message {
346 Message::Response { id, result } => self.on_response(id, result),
347 Message::Error { id, message, .. } => self.on_error(id, message),
348 Message::Notification { method, params } => {
349 (self.on_notification(&method, params), vec![])
350 }
351 Message::Request { id, method, params } => self.on_request(id, &method, params),
352 }
353 }
354
355 fn on_response(&mut self, id: u64, result: Value) -> (Vec<AgentEvent>, Vec<Message>) {
356 match self.pending.remove(&id) {
357 Some(Pending::Initialize) => {
358 self.ready = true;
359 let capabilities = parse_agent_capabilities(&result);
360 let queued = std::mem::take(&mut self.queued);
364 let messages = queued.into_iter().filter_map(|r| self.encode(r)).collect();
365 (vec![AgentEvent::Ready { capabilities }], messages)
366 }
367 Some(Pending::NewSession) => {
368 let Some(wire) = result.get("sessionId").and_then(Value::as_str) else {
369 return (
370 vec![AgentEvent::Failed {
371 session: SessionId::new(0),
372 message: "session/new returned no sessionId".into(),
373 }],
374 vec![],
375 );
376 };
377 self.next_session += 1;
378 let session = SessionId::new(self.next_session);
379 self.sessions.push((session, wire.to_string()));
380
381 let mut events = vec![AgentEvent::SessionStarted { session }];
385 if let Some(modes) = parse_session_modes(session, result.get("modes")) {
386 events.push(modes);
387 }
388 (events, vec![])
389 }
390 Some(Pending::Prompt(session)) => {
391 let reason = match result.get("stopReason").and_then(Value::as_str) {
392 Some("cancelled") => StopReason::Cancelled,
393 Some("refusal") => StopReason::Refusal,
394 Some("max_tokens") => StopReason::MaxTokens,
395 _ => StopReason::EndTurn,
396 };
397 self.expire_terminal_grants(session);
398 (vec![AgentEvent::TurnEnded { session, reason }], vec![])
399 }
400 Some(Pending::SetMode { session, mode }) => {
401 (vec![AgentEvent::ModeChanged { session, mode }], vec![])
402 }
403 None => (vec![], vec![]),
404 }
405 }
406
407 fn on_error(&mut self, id: u64, message: String) -> (Vec<AgentEvent>, Vec<Message>) {
408 let session = match self.pending.remove(&id) {
409 Some(Pending::Prompt(session)) => session,
410 Some(Pending::SetMode { session, .. }) => session,
413 _ => SessionId::new(0),
414 };
415 self.expire_terminal_grants(session);
419 (vec![AgentEvent::Failed { session, message }], vec![])
420 }
421
422 fn on_notification(&mut self, method: &str, params: Value) -> Vec<AgentEvent> {
423 if method != "session/update" {
424 return Vec::new(); }
426 let Some(session) =
427 params.get("sessionId").and_then(Value::as_str).and_then(|w| self.our_session(w))
428 else {
429 return Vec::new();
430 };
431 let Some(update) = params.get("update") else { return Vec::new() };
432 let kind = update.get("sessionUpdate").and_then(Value::as_str).unwrap_or_default();
433
434 match kind {
435 "agent_message_chunk" => text_of(update)
436 .map(|text| vec![AgentEvent::MessageChunk { session, text }])
437 .unwrap_or_default(),
438 "agent_thought_chunk" => text_of(update)
439 .map(|text| vec![AgentEvent::ThoughtChunk { session, text }])
440 .unwrap_or_default(),
441 "tool_call" | "tool_call_update" => self.events_from_tool_call(session, update),
443 "current_mode_update" => update
446 .get("modeId")
447 .and_then(Value::as_str)
448 .map(|mode| vec![AgentEvent::ModeChanged { session, mode: mode.to_string() }])
449 .unwrap_or_default(),
450 _ => Vec::new(),
451 }
452 }
453
454 fn events_from_tool_call(&mut self, session: SessionId, update: &Value) -> Vec<AgentEvent> {
455 let Some(contents) = update.get("content").and_then(Value::as_array) else {
456 return Vec::new();
457 };
458 let mut events = Vec::new();
459 for content in contents {
460 match content.get("type").and_then(Value::as_str) {
461 Some("diff") => {
462 let (Some(path), Some(new_text)) = (
463 content.get("path").and_then(Value::as_str),
464 content.get("newText").and_then(Value::as_str),
465 ) else {
466 continue;
467 };
468 self.next_id += 1;
469 events.push(AgentEvent::ProposedEdit {
470 session,
471 proposal: ProposalId::new(self.next_id),
472 path: PathBuf::from(path),
473 old_text: content
474 .get("oldText")
475 .and_then(Value::as_str)
476 .map(str::to_string),
477 new_text: new_text.to_string(),
478 });
479 }
480 Some("terminal") => {
481 let Some(wire) = content.get("terminalId").and_then(Value::as_str) else {
482 continue;
483 };
484 if let Some(binding) =
485 self.terminal_binding(wire).filter(|binding| binding.session == session)
486 {
487 events.push(AgentEvent::TerminalAttached {
488 session,
489 terminal: binding.local,
490 });
491 }
492 }
493 _ => {}
494 }
495 }
496 events
497 }
498
499 fn on_request(
502 &mut self,
503 id: u64,
504 method: &str,
505 params: Value,
506 ) -> (Vec<AgentEvent>, Vec<Message>) {
507 let session = params
508 .get("sessionId")
509 .and_then(Value::as_str)
510 .and_then(|w| self.our_session(w))
511 .unwrap_or(SessionId::new(0));
512
513 match method {
514 "fs/read_text_file" => {
515 let Some(path) = params.get("path").and_then(Value::as_str) else {
516 return (
517 vec![],
518 vec![Message::Error {
519 id,
520 code: -32602,
521 message: "fs/read_text_file needs a path".into(),
522 }],
523 );
524 };
525 self.next_id += 1;
526 let request = ReadRequestId::new(self.next_id);
527 self.reads.insert(request, id);
528 (
529 vec![AgentEvent::ReadFileRequested {
530 session,
531 request,
532 path: PathBuf::from(path),
533 }],
534 vec![],
535 )
536 }
537 "fs/write_text_file" => {
544 let (Some(path), Some(content)) = (
545 params.get("path").and_then(Value::as_str),
546 params.get("content").and_then(Value::as_str),
547 ) else {
548 return (
549 vec![],
550 vec![Message::Error {
551 id,
552 code: -32602,
553 message: "fs/write_text_file needs a path and content".into(),
554 }],
555 );
556 };
557
558 self.next_id += 1;
559 (
560 vec![AgentEvent::ProposedEdit {
561 session,
562 proposal: ProposalId::new(self.next_id),
563 path: PathBuf::from(path),
564 old_text: None,
567 new_text: content.to_string(),
568 }],
569 vec![Message::Response { id, result: Value::Null }],
570 )
571 }
572 "session/request_permission" => {
573 let options: Vec<PermissionOption> = params
574 .get("options")
575 .and_then(Value::as_array)
576 .map(|opts| {
577 opts.iter()
578 .filter_map(|o| {
579 Some(PermissionOption {
580 id: o.get("optionId").and_then(Value::as_str)?.to_string(),
581 kind: o
582 .get("kind")
583 .and_then(Value::as_str)
584 .unwrap_or_default()
585 .to_string(),
586 })
587 })
588 .collect()
589 })
590 .unwrap_or_default();
591
592 let tool = params.get("toolCall");
593 let summary = tool
594 .and_then(|t| t.get("title"))
595 .and_then(Value::as_str)
596 .unwrap_or("run a tool")
597 .to_string();
598 let command = argv_of(tool);
599 let terminal_spec = terminal_spec_of_permission(tool);
600
601 self.next_id += 1;
602 let request = PermissionRequestId::new(self.next_id);
603 self.permissions.insert(
604 request,
605 PendingPermission {
606 wire_request: id,
607 session,
608 options,
609 terminal_spec: terminal_spec.clone(),
610 },
611 );
612
613 (
614 vec![AgentEvent::PermissionRequested {
615 session,
616 request,
617 summary,
618 command,
619 terminal_spec,
620 }],
621 vec![],
622 )
623 }
624 "terminal/create" => {
625 if session == SessionId::new(0) {
626 return invalid_params(id, "terminal/create needs a known sessionId");
627 }
628 let spec = match terminal_spec_of_create(¶ms) {
629 Ok(spec) => spec,
630 Err(message) => return invalid_params(id, message),
631 };
632 let output_byte_limit = match output_limit(¶ms) {
633 Ok(limit) => limit,
634 Err(message) => return invalid_params(id, message),
635 };
636 let preauthorized = self
637 .one_shot_terminal_grants
638 .iter()
639 .position(|(owner, granted)| *owner == session && *granted == spec)
640 .map(|index| {
641 self.one_shot_terminal_grants.remove(index);
642 true
643 })
644 .unwrap_or(false);
645 let event = self.allocate_terminal_request(
646 id,
647 session,
648 TerminalRpcKind::Create,
649 AgentTerminalOperation::Create { spec, output_byte_limit, preauthorized },
650 );
651 (vec![event], vec![])
652 }
653 "terminal/output" | "terminal/wait_for_exit" | "terminal/kill" | "terminal/release" => {
654 if session == SessionId::new(0) {
655 return invalid_params(id, format!("{method} needs a known sessionId"));
656 }
657 let Some(wire) = params.get("terminalId").and_then(Value::as_str) else {
658 return invalid_params(id, format!("{method} needs a terminalId"));
659 };
660 let Some(terminal) = self.live_terminal(wire, session) else {
661 return invalid_params(id, format!("unknown or released terminalId: {wire}"));
662 };
663 let (kind, operation) = match method {
664 "terminal/output" => (
665 TerminalRpcKind::Output(terminal),
666 AgentTerminalOperation::Output { terminal },
667 ),
668 "terminal/wait_for_exit" => (
669 TerminalRpcKind::Wait(terminal),
670 AgentTerminalOperation::WaitForExit { terminal },
671 ),
672 "terminal/kill" => {
673 (TerminalRpcKind::Kill(terminal), AgentTerminalOperation::Kill { terminal })
674 }
675 "terminal/release" => (
676 TerminalRpcKind::Release(terminal),
677 AgentTerminalOperation::Release { terminal },
678 ),
679 _ => unreachable!("matched terminal methods above"),
680 };
681 let event = self.allocate_terminal_request(id, session, kind, operation);
682 (vec![event], vec![])
683 }
684 _ => (
687 vec![],
688 vec![Message::Error {
689 id,
690 code: -32601,
691 message: format!("{method} is not supported"),
692 }],
693 ),
694 }
695 }
696}
697
698fn parse_session_modes(session: SessionId, modes: Option<&Value>) -> Option<AgentEvent> {
704 let modes = modes?;
705 let current = modes.get("currentModeId").and_then(Value::as_str)?;
706 let available: Vec<SessionMode> = modes
707 .get("availableModes")
708 .and_then(Value::as_array)?
709 .iter()
710 .filter_map(|mode| {
711 let id = mode.get("id").and_then(Value::as_str)?.to_string();
712 Some(SessionMode {
713 name: mode.get("name").and_then(Value::as_str).unwrap_or(&id).to_string(),
716 description: mode.get("description").and_then(Value::as_str).map(str::to_string),
717 id,
718 })
719 })
720 .collect();
721 if available.is_empty() {
722 return None;
723 }
724 Some(AgentEvent::ModesAvailable { session, current: current.to_string(), available })
725}
726
727fn terminal_spec_of_create(params: &Value) -> Result<TerminalSpec, String> {
728 let program = params
729 .get("command")
730 .and_then(Value::as_str)
731 .filter(|program| !program.is_empty())
732 .ok_or_else(|| "terminal/create needs a non-empty command".to_string())?;
733 let args = string_array(params.get("args"), false, "terminal/create args")?;
734 let cwd = params
735 .get("cwd")
736 .and_then(Value::as_str)
737 .map(PathBuf::from)
738 .filter(|path| valid_absolute_path(path))
739 .ok_or_else(|| {
740 "terminal/create cwd must be an absolute path without traversal".to_string()
741 })?;
742 let env = env_array(params.get("env"), false, "terminal/create env")?;
743 Ok(TerminalSpec { program: program.into(), args, cwd, env })
744}
745
746fn terminal_spec_of_permission(tool: Option<&Value>) -> Option<TerminalSpec> {
747 let raw = tool?.get("rawInput")?;
748 let command = string_array(raw.get("command"), true, "permission command").ok()?;
749 let (program, args) = command.split_first()?;
750 let cwd = raw.get("cwd")?.as_str().map(PathBuf::from)?;
751 if !valid_absolute_path(&cwd) {
752 return None;
753 }
754 let env = env_array(raw.get("env"), true, "permission env").ok()?;
755 Some(TerminalSpec { program: program.clone(), args: args.to_vec(), cwd, env })
756}
757
758fn string_array(value: Option<&Value>, required: bool, label: &str) -> Result<Vec<String>, String> {
759 let Some(value) = value else {
760 return if required { Err(format!("{label} must be an array")) } else { Ok(Vec::new()) };
761 };
762 let array = value.as_array().ok_or_else(|| format!("{label} must be an array"))?;
763 array
764 .iter()
765 .map(|item| {
766 item.as_str()
767 .map(str::to_owned)
768 .ok_or_else(|| format!("{label} must contain only strings"))
769 })
770 .collect()
771}
772
773fn env_array(
774 value: Option<&Value>,
775 required: bool,
776 label: &str,
777) -> Result<Vec<(String, String)>, String> {
778 let Some(value) = value else {
779 return if required { Err(format!("{label} must be an array")) } else { Ok(Vec::new()) };
780 };
781 let array = value.as_array().ok_or_else(|| format!("{label} must be an array"))?;
782 array
783 .iter()
784 .map(|item| {
785 let name = item
786 .get("name")
787 .and_then(Value::as_str)
788 .filter(|name| !name.is_empty() && !name.contains(['=', '\0']))
789 .ok_or_else(|| format!("{label} entries need a valid name"))?;
790 let value = item
791 .get("value")
792 .and_then(Value::as_str)
793 .filter(|value| !value.contains('\0'))
794 .ok_or_else(|| format!("{label} entries need a string value"))?;
795 Ok((name.to_owned(), value.to_owned()))
796 })
797 .collect()
798}
799
800fn valid_absolute_path(path: &Path) -> bool {
801 path.is_absolute()
802 && !path.components().any(|component| matches!(component, std::path::Component::ParentDir))
803}
804
805fn output_limit(params: &Value) -> Result<usize, String> {
806 let Some(value) = params.get("outputByteLimit") else {
807 return Ok(DEFAULT_OUTPUT_LIMIT);
808 };
809 let limit = value
810 .as_u64()
811 .ok_or_else(|| "outputByteLimit must be a non-negative integer".to_string())?;
812 Ok(limit.min(MAX_OUTPUT_LIMIT as u64) as usize)
813}
814
815fn exit_status(exit: TerminalExit) -> Value {
816 json!({ "exitCode": exit.code, "signal": exit.signal })
817}
818
819fn invalid_params(id: u64, message: impl Into<String>) -> (Vec<AgentEvent>, Vec<Message>) {
820 (vec![], vec![Message::Error { id, code: -32602, message: message.into() }])
821}
822
823fn text_of(update: &Value) -> Option<String> {
825 update.get("content")?.get("text")?.as_str().map(str::to_string)
826}
827
828fn parse_agent_capabilities(result: &Value) -> AgentCapabilities {
831 let caps = result.get("agentCapabilities");
832 let flag = |on: Option<&Value>, key: &str| {
833 on.and_then(|v| v.get(key)).and_then(Value::as_bool).unwrap_or(false)
834 };
835 let prompt = caps.and_then(|c| c.get("promptCapabilities"));
836 AgentCapabilities {
837 load_session: flag(caps, "loadSession"),
838 prompt_capabilities: PromptCapabilities {
839 image: flag(prompt, "image"),
840 audio: flag(prompt, "audio"),
841 embedded_context: flag(prompt, "embeddedContext"),
842 },
843 }
844}
845
846fn argv_of(tool: Option<&Value>) -> Vec<String> {
850 let Some(raw) = tool.and_then(|t| t.get("rawInput")) else { return Vec::new() };
851 if let Some(array) = raw.get("command").and_then(Value::as_array) {
852 return array.iter().filter_map(Value::as_str).map(str::to_string).collect();
853 }
854 raw.get("command").and_then(Value::as_str).map(|s| vec![s.to_string()]).unwrap_or_default()
857}
858
859fn choose_option(options: &[PermissionOption], decision: PermissionDecision) -> Option<String> {
861 let wanted = match decision {
862 PermissionDecision::AllowOnce => "allow_once",
863 PermissionDecision::AllowAlways => "allow_always",
864 PermissionDecision::RejectOnce => "reject_once",
865 PermissionDecision::RejectAlways => "reject_always",
866 };
867 options
868 .iter()
869 .find(|o| o.kind == wanted)
870 .or_else(|| {
871 let fallback = if decision.allows() { "allow_once" } else { "reject_once" };
874 options.iter().find(|o| o.kind == fallback)
875 })
876 .map(|o| o.id.clone())
877}
878
879#[cfg(test)]
880mod tests {
881 const CWD: &str = if cfg!(windows) { r"C:\proj" } else { "/proj" };
887
888 use super::*;
889
890 fn connected_with(result: Value) -> (Translator, Vec<AgentEvent>) {
893 let mut t = Translator::new();
894 let init = t.initialize(ClientCapabilities::default());
895 let Message::Request { id, .. } = init else { panic!("initialize is a request") };
896 t.incoming(Message::Response { id, result: json!({}) });
897
898 let messages = t.outgoing(AgentRequest::NewSession { cwd: "/proj".into() });
899 let Message::Request { id, .. } = messages[0].clone() else { panic!() };
900 let (events, _) = t.incoming(Message::Response { id, result });
901 (t, events)
902 }
903
904 fn connected() -> (Translator, SessionId) {
905 let mut t = Translator::new();
906 let init = t.initialize(ClientCapabilities::default());
907 let Message::Request { id, .. } = init else { panic!("initialize is a request") };
908 t.incoming(Message::Response { id, result: json!({}) });
909
910 let messages = t.outgoing(AgentRequest::NewSession { cwd: "/proj".into() });
911 let Message::Request { id, .. } = messages[0].clone() else { panic!() };
912 let (events, _) =
913 t.incoming(Message::Response { id, result: json!({ "sessionId": "s-1" }) });
914 match events.as_slice() {
915 [AgentEvent::SessionStarted { session }] => (t, *session),
916 other => panic!("expected a session, got {other:?}"),
917 }
918 }
919
920 fn update(session: &str, body: Value) -> Message {
921 Message::Notification {
922 method: "session/update".into(),
923 params: json!({ "sessionId": session, "update": body }),
924 }
925 }
926
927 #[test]
928 fn the_handshake_result_is_parsed_rather_than_discarded() {
929 let mut t = Translator::new();
930 let init = t.initialize(ClientCapabilities::default());
931 let Message::Request { id, .. } = init else { panic!("initialize is a request") };
932 let (events, _) = t.incoming(Message::Response {
933 id,
934 result: json!({
935 "protocolVersion": 1,
936 "agentCapabilities": { "loadSession": true }
937 }),
938 });
939 assert!(
940 matches!(
941 events.as_slice(),
942 [AgentEvent::Ready { capabilities }] if capabilities.load_session
943 ),
944 "{events:?}"
945 );
946 }
947
948 #[test]
949 fn an_agent_that_says_nothing_is_assumed_to_support_nothing_extra() {
950 let mut t = Translator::new();
953 let init = t.initialize(ClientCapabilities::default());
954 let Message::Request { id, .. } = init else { panic!("initialize is a request") };
955 let (events, _) = t.incoming(Message::Response { id, result: json!({}) });
956 assert!(
957 matches!(
958 events.as_slice(),
959 [AgentEvent::Ready { capabilities }] if !capabilities.load_session
960 ),
961 "{events:?}"
962 );
963 }
964
965 #[test]
966 fn queued_requests_still_go_out_after_the_handshake() {
967 let mut t = Translator::new();
970 let init = t.initialize(ClientCapabilities::default());
971 let Message::Request { id, .. } = init else { panic!("initialize is a request") };
972
973 let queued = t.outgoing(AgentRequest::NewSession { cwd: "/proj".into() });
974 assert!(queued.is_empty(), "queued before the handshake completes, not sent yet");
975
976 let (_, messages) = t.incoming(Message::Response { id, result: json!({}) });
977 assert_eq!(messages.len(), 1, "the queued session/new goes out now: {messages:?}");
978 assert!(matches!(&messages[0], Message::Request { method, .. } if method == "session/new"));
979 }
980
981 #[test]
982 fn initialize_advertises_the_file_capabilities() {
983 let mut t = Translator::new();
984 let Message::Request { method, params, .. } = t.initialize(ClientCapabilities::default())
985 else {
986 panic!("initialize is a request")
987 };
988 assert_eq!(method, "initialize");
989 assert_eq!(params["clientCapabilities"]["fs"]["readTextFile"], json!(true));
990 assert_eq!(params["clientCapabilities"]["fs"]["writeTextFile"], json!(true));
991 }
992
993 #[test]
994 fn terminal_capability_is_advertised_only_when_enabled() {
995 let mut disabled = Translator::new();
996 let Message::Request { params, .. } = disabled.initialize(ClientCapabilities::default())
997 else {
998 panic!()
999 };
1000 assert_eq!(params["clientCapabilities"]["terminal"], json!(false));
1001
1002 let mut enabled = Translator::new();
1003 let Message::Request { params, .. } = enabled
1004 .initialize(ClientCapabilities { terminal: true, ..ClientCapabilities::default() })
1005 else {
1006 panic!()
1007 };
1008 assert_eq!(params["clientCapabilities"]["terminal"], json!(true));
1009 }
1010
1011 #[test]
1014 fn requests_made_before_the_handshake_completes_are_queued_not_dropped() {
1015 let mut t = Translator::new();
1016 let init = t.initialize(ClientCapabilities::default());
1017 let Message::Request { id, .. } = init else { panic!() };
1018
1019 assert!(t.outgoing(AgentRequest::NewSession { cwd: "/proj".into() }).is_empty());
1020
1021 let (_, messages) = t.incoming(Message::Response { id, result: json!({}) });
1022 assert!(
1023 matches!(&messages[0], Message::Request { method, .. } if method == "session/new"),
1024 "the queued request goes out once we are ready, got {messages:?}"
1025 );
1026 }
1027
1028 #[test]
1029 fn a_session_id_is_ours_not_the_agents_string() {
1030 let (t, session) = connected();
1031 assert_eq!(t.wire_session(session), Some("s-1"));
1032 assert_eq!(t.our_session("s-1"), Some(session));
1033 assert_eq!(t.our_session("nope"), None);
1034 }
1035
1036 #[test]
1037 fn a_prompt_carries_the_context_before_the_question() {
1038 let (mut t, session) = connected();
1039 let messages = t.outgoing(AgentRequest::Prompt {
1040 session,
1041 text: "fix it".into(),
1042 context: "project: proj".into(),
1043 });
1044 let Message::Request { params, method, .. } = &messages[0] else { panic!() };
1045 assert_eq!(method, "session/prompt");
1046 assert_eq!(params["sessionId"], json!("s-1"));
1047 assert_eq!(params["prompt"][0]["text"], json!("project: proj"));
1048 assert_eq!(params["prompt"][1]["text"], json!("fix it"));
1049 }
1050
1051 #[test]
1052 fn streamed_text_and_reasoning_are_distinguished() {
1053 let (mut t, session) = connected();
1054
1055 let (events, _) = t.incoming(update(
1056 "s-1",
1057 json!({ "sessionUpdate": "agent_message_chunk", "content": { "type": "text", "text": "hi" } }),
1058 ));
1059 assert_eq!(events, vec![AgentEvent::MessageChunk { session, text: "hi".into() }]);
1060
1061 let (events, _) = t.incoming(update(
1062 "s-1",
1063 json!({ "sessionUpdate": "agent_thought_chunk", "content": { "type": "text", "text": "hmm" } }),
1064 ));
1065 assert_eq!(events, vec![AgentEvent::ThoughtChunk { session, text: "hmm".into() }]);
1066 }
1067
1068 #[test]
1070 fn an_edit_is_lifted_out_of_a_tool_call_diff() {
1071 let (mut t, session) = connected();
1072 let (events, _) = t.incoming(update(
1073 "s-1",
1074 json!({
1075 "sessionUpdate": "tool_call",
1076 "title": "Edit main.rs",
1077 "content": [{
1078 "type": "diff",
1079 "path": "/proj/main.rs",
1080 "oldText": "fn main() {}\n",
1081 "newText": "fn run() {}\n"
1082 }]
1083 }),
1084 ));
1085 match events.as_slice() {
1086 [AgentEvent::ProposedEdit { session: s, path, old_text, new_text, .. }] => {
1087 assert_eq!(*s, session);
1088 assert_eq!(path, &PathBuf::from("/proj/main.rs"));
1089 assert_eq!(old_text.as_deref(), Some("fn main() {}\n"));
1090 assert_eq!(new_text, "fn run() {}\n");
1091 }
1092 other => panic!("expected a proposal, got {other:?}"),
1093 }
1094 }
1095
1096 #[test]
1100 fn a_session_reports_the_modes_the_agent_offered() {
1101 let (_, events) = connected_with(json!({
1102 "sessionId": "s-1",
1103 "modes": {
1104 "currentModeId": "read-only",
1105 "availableModes": [
1106 {"id": "read-only", "name": "Read Only", "description": "Can read files."},
1107 {"id": "auto", "name": "Default", "description": "Can read and edit."}
1108 ]
1109 }
1110 }));
1111
1112 let (current, available) = events
1113 .iter()
1114 .find_map(|event| match event {
1115 AgentEvent::ModesAvailable { current, available, .. } => {
1116 Some((current.clone(), available.clone()))
1117 }
1118 _ => None,
1119 })
1120 .expect("the modes reach the client");
1121 assert_eq!(current, "read-only", "the session starts in the agent's choice");
1122 assert_eq!(available.len(), 2);
1123 assert_eq!(available[1].name, "Default");
1124 assert_eq!(available[1].description.as_deref(), Some("Can read and edit."));
1125 }
1126
1127 #[test]
1129 fn a_session_without_modes_reports_none() {
1130 let (_, events) = connected_with(json!({ "sessionId": "s-1" }));
1131 assert!(
1132 !events.iter().any(|e| matches!(e, AgentEvent::ModesAvailable { .. })),
1133 "got {events:?}"
1134 );
1135 }
1136
1137 #[test]
1139 fn the_agent_reporting_a_mode_change_is_what_moves_the_client() {
1140 let (mut t, session) = connected();
1141
1142 let messages = t.outgoing(AgentRequest::SetMode { session, mode: "auto".into() });
1143 assert!(
1144 matches!(&messages[0], Message::Request { method, params, .. }
1145 if method == "session/set_mode"
1146 && params["modeId"] == "auto"
1147 && params["sessionId"] == "s-1"),
1148 "got {messages:?}"
1149 );
1150
1151 let (events, _) = t.incoming(update(
1152 "s-1",
1153 json!({"sessionUpdate": "current_mode_update", "modeId": "auto"}),
1154 ));
1155 assert_eq!(events.as_slice(), [AgentEvent::ModeChanged { session, mode: "auto".into() }]);
1156 }
1157
1158 #[test]
1162 fn a_bare_success_is_the_agent_saying_it_changed_the_mode() {
1163 let (mut t, session) = connected();
1164
1165 let messages = t.outgoing(AgentRequest::SetMode { session, mode: "auto".into() });
1166 let Message::Request { id, .. } = messages[0].clone() else { panic!("a request") };
1167
1168 let (events, _) = t.incoming(Message::Response { id, result: json!({}) });
1169 assert_eq!(events.as_slice(), [AgentEvent::ModeChanged { session, mode: "auto".into() }]);
1170 }
1171
1172 #[test]
1175 fn a_refused_mode_change_reports_the_refusal_and_moves_nothing() {
1176 let (mut t, session) = connected();
1177
1178 let messages = t.outgoing(AgentRequest::SetMode { session, mode: "full-access".into() });
1179 let Message::Request { id, .. } = messages[0].clone() else { panic!("a request") };
1180
1181 let (events, _) =
1182 t.incoming(Message::Error { id, code: -32602, message: "unknown mode".into() });
1183 assert!(
1184 !events.iter().any(|e| matches!(e, AgentEvent::ModeChanged { .. })),
1185 "got {events:?}"
1186 );
1187 assert!(
1188 matches!(&events[0], AgentEvent::Failed { session: s, .. } if *s == session),
1189 "got {events:?}"
1190 );
1191 }
1192
1193 #[test]
1194 fn a_new_file_has_no_old_text() {
1195 let (mut t, _) = connected();
1196 let (events, _) = t.incoming(update(
1197 "s-1",
1198 json!({
1199 "sessionUpdate": "tool_call",
1200 "content": [{ "type": "diff", "path": "/proj/new.rs", "newText": "hello\n" }]
1201 }),
1202 ));
1203 assert!(matches!(events.as_slice(), [AgentEvent::ProposedEdit { old_text: None, .. }]));
1204 }
1205
1206 #[test]
1207 fn non_diff_tool_content_produces_no_proposal() {
1208 let (mut t, _) = connected();
1209 let (events, _) = t.incoming(update(
1210 "s-1",
1211 json!({
1212 "sessionUpdate": "tool_call",
1213 "content": [{ "type": "content", "content": { "type": "text", "text": "ran it" } }]
1214 }),
1215 ));
1216 assert!(events.is_empty());
1217 }
1218
1219 #[test]
1220 fn updates_for_an_unknown_session_are_ignored() {
1221 let (mut t, _) = connected();
1222 let (events, _) = t.incoming(update(
1223 "someone-else",
1224 json!({ "sessionUpdate": "agent_message_chunk", "content": { "type": "text", "text": "hi" } }),
1225 ));
1226 assert!(events.is_empty(), "not our session, not our problem");
1227 }
1228
1229 #[test]
1230 fn an_unmodelled_update_kind_is_skipped_rather_than_fatal() {
1231 let (mut t, _) = connected();
1232 let (events, replies) =
1233 t.incoming(update("s-1", json!({ "sessionUpdate": "plan", "entries": [] })));
1234 assert!(events.is_empty() && replies.is_empty(), "spec churn must not break us");
1235 }
1236
1237 #[test]
1240 fn a_file_read_becomes_an_event_and_its_answer_goes_back_to_the_right_id() {
1241 let (mut t, session) = connected();
1242 let (events, replies) = t.incoming(Message::Request {
1243 id: 42,
1244 method: "fs/read_text_file".into(),
1245 params: json!({ "sessionId": "s-1", "path": "/proj/main.rs" }),
1246 });
1247 assert!(replies.is_empty(), "we answer once the model serves the text");
1248 let AgentEvent::ReadFileRequested { session: s, request, path } = events[0].clone() else {
1249 panic!("expected a read, got {events:?}")
1250 };
1251 assert_eq!((s, path), (session, PathBuf::from("/proj/main.rs")));
1252
1253 let out = t.outgoing(AgentRequest::FileContents {
1254 session,
1255 request,
1256 path: "/proj/main.rs".into(),
1257 contents: Some("live text".into()),
1258 });
1259 assert_eq!(
1260 out,
1261 vec![Message::Response { id: 42, result: json!({ "content": "live text" }) }]
1262 );
1263 }
1264
1265 #[test]
1267 fn a_read_we_cannot_serve_is_an_error_not_an_empty_file() {
1268 let (mut t, session) = connected();
1269 let (events, _) = t.incoming(Message::Request {
1270 id: 9,
1271 method: "fs/read_text_file".into(),
1272 params: json!({ "sessionId": "s-1", "path": "/proj/gone.rs" }),
1273 });
1274 let AgentEvent::ReadFileRequested { request, .. } = events[0].clone() else { panic!() };
1275
1276 let out = t.outgoing(AgentRequest::FileContents {
1277 session,
1278 request,
1279 path: "/proj/gone.rs".into(),
1280 contents: None,
1281 });
1282 assert!(matches!(out.as_slice(), [Message::Error { id: 9, .. }]), "got {out:?}");
1283 }
1284
1285 #[test]
1288 fn two_reads_of_the_same_file_are_both_answered() {
1289 let (mut t, session) = connected();
1290
1291 let mut requests = Vec::new();
1292 for wire_id in [10, 11] {
1293 let (events, _) = t.incoming(Message::Request {
1294 id: wire_id,
1295 method: "fs/read_text_file".into(),
1296 params: json!({ "sessionId": "s-1", "path": "/proj/main.rs" }),
1297 });
1298 let AgentEvent::ReadFileRequested { request, .. } = events[0].clone() else {
1299 panic!("expected a read, got {events:?}")
1300 };
1301 requests.push(request);
1302 }
1303 assert_ne!(requests[0], requests[1], "each call gets its own id");
1304
1305 let mut answered = Vec::new();
1306 for (i, request) in requests.iter().enumerate() {
1307 let out = t.outgoing(AgentRequest::FileContents {
1308 session,
1309 request: *request,
1310 path: "/proj/main.rs".into(),
1311 contents: Some(format!("read {i}")),
1312 });
1313 match out.as_slice() {
1314 [Message::Response { id, .. }] => answered.push(*id),
1315 other => panic!("read {i} went unanswered: {other:?}"),
1316 }
1317 }
1318 assert_eq!(answered, vec![10, 11], "both wire ids answered, in order");
1319 }
1320
1321 #[test]
1324 fn a_write_becomes_a_proposal_and_is_acknowledged() {
1325 let (mut t, session) = connected();
1326 let (events, replies) = t.incoming(Message::Request {
1327 id: 21,
1328 method: "fs/write_text_file".into(),
1329 params: json!({
1330 "sessionId": "s-1",
1331 "path": "/proj/main.rs",
1332 "content": "fn run() {}\n"
1333 }),
1334 });
1335
1336 match events.as_slice() {
1337 [AgentEvent::ProposedEdit { session: s, path, old_text, new_text, .. }] => {
1338 assert_eq!(*s, session);
1339 assert_eq!(path, &PathBuf::from("/proj/main.rs"));
1340 assert_eq!(new_text, "fn run() {}\n");
1341 assert_eq!(*old_text, None, "the client knows the base; the agent did not say");
1342 }
1343 other => panic!("expected a proposal, got {other:?}"),
1344 }
1345 assert!(
1346 matches!(replies.as_slice(), [Message::Response { id: 21, .. }]),
1347 "the write must be acknowledged, not refused: {replies:?}"
1348 );
1349 }
1350
1351 #[test]
1352 fn a_malformed_write_is_refused_rather_than_silently_dropped() {
1353 let (mut t, _) = connected();
1354 let (events, replies) = t.incoming(Message::Request {
1355 id: 22,
1356 method: "fs/write_text_file".into(),
1357 params: json!({ "sessionId": "s-1", "path": "/proj/main.rs" }),
1358 });
1359 assert!(events.is_empty());
1360 assert!(matches!(replies.as_slice(), [Message::Error { id: 22, .. }]));
1361 }
1362
1363 #[test]
1364 fn a_permission_request_surfaces_the_command_as_argv() {
1365 let (mut t, _) = connected();
1366 let (events, _) = t.incoming(Message::Request {
1367 id: 5,
1368 method: "session/request_permission".into(),
1369 params: json!({
1370 "sessionId": "s-1",
1371 "toolCall": { "title": "Run tests", "rawInput": { "command": ["cargo", "test"] } },
1372 "options": [
1373 { "optionId": "o1", "kind": "allow_once" },
1374 { "optionId": "o2", "kind": "reject_once" }
1375 ]
1376 }),
1377 });
1378 match events.as_slice() {
1379 [AgentEvent::PermissionRequested { summary, command, .. }] => {
1380 assert_eq!(summary, "Run tests");
1381 assert_eq!(command, &["cargo", "test"]);
1382 }
1383 other => panic!("expected a permission request, got {other:?}"),
1384 }
1385 }
1386
1387 #[test]
1388 fn a_string_command_is_not_split_on_spaces() {
1389 let (mut t, _) = connected();
1391 let (events, _) = t.incoming(Message::Request {
1392 id: 5,
1393 method: "session/request_permission".into(),
1394 params: json!({
1395 "sessionId": "s-1",
1396 "toolCall": { "rawInput": { "command": "rm -rf 'my dir'" } },
1397 "options": []
1398 }),
1399 });
1400 match events.as_slice() {
1401 [AgentEvent::PermissionRequested { command, terminal_spec, .. }] => {
1402 assert_eq!(command, &["rm -rf 'my dir'"], "one element, quoting intact");
1403 assert!(terminal_spec.is_none(), "shell text must never become an exact grant");
1404 }
1405 other => panic!("got {other:?}"),
1406 }
1407 }
1408
1409 #[test]
1410 fn the_humans_answer_selects_the_matching_option() {
1411 for (decision, expected) in [
1412 (PermissionDecision::AllowOnce, "o1"),
1413 (PermissionDecision::AllowAlways, "o2"),
1414 (PermissionDecision::RejectOnce, "o3"),
1415 ] {
1416 let (mut t, _) = connected();
1417 let (events, _) = t.incoming(Message::Request {
1418 id: 5,
1419 method: "session/request_permission".into(),
1420 params: json!({
1421 "sessionId": "s-1",
1422 "toolCall": {},
1423 "options": [
1424 { "optionId": "o1", "kind": "allow_once" },
1425 { "optionId": "o2", "kind": "allow_always" },
1426 { "optionId": "o3", "kind": "reject_once" }
1427 ]
1428 }),
1429 });
1430 let AgentEvent::PermissionRequested { request, .. } = events[0].clone() else {
1431 panic!()
1432 };
1433
1434 let out = t.outgoing(AgentRequest::Permission { request, decision });
1435 let Message::Response { result, .. } = &out[0] else { panic!("got {out:?}") };
1436 assert_eq!(result["outcome"]["optionId"], json!(expected), "for {decision:?}");
1437 }
1438 }
1439
1440 #[test]
1441 fn cancelling_a_permission_prompt_uses_the_acp_cancelled_outcome() {
1442 let (mut t, _) = connected();
1443 let (events, _) = t.incoming(Message::Request {
1444 id: 5,
1445 method: "session/request_permission".into(),
1446 params: json!({
1447 "sessionId": "s-1",
1448 "toolCall": {},
1449 "options": [{ "optionId": "reject", "kind": "reject_once" }]
1450 }),
1451 });
1452 let AgentEvent::PermissionRequested { request, .. } = events[0].clone() else { panic!() };
1453
1454 let out = t.outgoing(AgentRequest::PermissionCancelled { request });
1455 let Message::Response { result, .. } = &out[0] else { panic!("got {out:?}") };
1456 assert_eq!(result, &json!({ "outcome": { "outcome": "cancelled" } }));
1457 }
1458
1459 #[test]
1461 fn a_missing_option_never_flips_the_answer() {
1462 let (mut t, _) = connected();
1463 let (events, _) = t.incoming(Message::Request {
1464 id: 5,
1465 method: "session/request_permission".into(),
1466 params: json!({
1467 "sessionId": "s-1",
1468 "toolCall": {},
1469 "options": [{ "optionId": "only-reject", "kind": "reject_once" }]
1470 }),
1471 });
1472 let AgentEvent::PermissionRequested { request, .. } = events[0].clone() else { panic!() };
1473
1474 let out = t.outgoing(AgentRequest::Permission {
1475 request,
1476 decision: PermissionDecision::AllowAlways,
1477 });
1478 let Message::Response { result, .. } = &out[0] else { panic!() };
1479 assert_eq!(
1480 result["outcome"]["outcome"],
1481 json!("cancelled"),
1482 "no allow option offered, so we decline rather than pick a reject"
1483 );
1484 }
1485
1486 #[test]
1487 fn terminal_create_becomes_a_structured_local_request() {
1488 let (mut t, session) = connected();
1489 let (events, replies) = t.incoming(Message::Request {
1490 id: 11,
1491 method: "terminal/create".into(),
1492 params: json!({
1493 "sessionId": "s-1",
1494 "command": "cargo",
1495 "args": ["test"],
1496 "cwd": CWD,
1497 "env": [],
1498 "outputByteLimit": 4096
1499 }),
1500 });
1501 assert!(replies.is_empty());
1502 assert!(matches!(events.as_slice(), [AgentEvent::TerminalRequest {
1503 session: owner,
1504 operation: termesh_core::AgentTerminalOperation::Create {
1505 spec,
1506 output_byte_limit: 4096,
1507 preauthorized: false,
1508 },
1509 ..
1510 }] if *owner == session && spec.program == "cargo" && spec.args == ["test"]));
1511 }
1512
1513 #[test]
1514 fn terminal_ids_correlate_output_wait_kill_and_release() {
1515 let (mut t, session) = connected();
1516 let (events, _) = t.incoming(Message::Request {
1517 id: 20,
1518 method: "terminal/create".into(),
1519 params: json!({
1520 "sessionId": "s-1", "command": "cargo", "args": ["test"],
1521 "cwd": CWD, "env": []
1522 }),
1523 });
1524 let AgentEvent::TerminalRequest { request, .. } = events[0].clone() else { panic!() };
1525 let terminal = termesh_core::TerminalId::new(7);
1526 let created = t.outgoing(AgentRequest::TerminalResponse {
1527 request,
1528 response: termesh_core::AgentTerminalResponse::Created { terminal },
1529 });
1530 assert_eq!(
1531 created,
1532 [Message::Response { id: 20, result: json!({ "terminalId": "termesh-7" }) }]
1533 );
1534
1535 let methods = [
1536 ("terminal/output", termesh_core::AgentTerminalOperation::Output { terminal }),
1537 (
1538 "terminal/wait_for_exit",
1539 termesh_core::AgentTerminalOperation::WaitForExit { terminal },
1540 ),
1541 ("terminal/kill", termesh_core::AgentTerminalOperation::Kill { terminal }),
1542 ("terminal/release", termesh_core::AgentTerminalOperation::Release { terminal }),
1543 ];
1544 for (offset, (method, expected)) in methods.into_iter().enumerate() {
1545 let (events, replies) = t.incoming(Message::Request {
1546 id: 30 + offset as u64,
1547 method: method.into(),
1548 params: json!({ "sessionId": "s-1", "terminalId": "termesh-7" }),
1549 });
1550 assert!(replies.is_empty());
1551 assert!(matches!(events.as_slice(), [AgentEvent::TerminalRequest {
1552 session: owner,
1553 operation,
1554 ..
1555 }] if *owner == session && *operation == expected));
1556 }
1557 }
1558
1559 #[test]
1560 fn terminal_responses_use_acp_shapes_and_release_invalidates_operations() {
1561 let (mut t, session) = connected();
1562 let (events, _) = t.incoming(Message::Request {
1563 id: 60,
1564 method: "terminal/create".into(),
1565 params: json!({
1566 "sessionId": "s-1", "command": "cargo", "cwd": CWD, "env": []
1567 }),
1568 });
1569 let AgentEvent::TerminalRequest { request, .. } = events[0].clone() else { panic!() };
1570 let terminal = TerminalId::new(9);
1571 let _ = t.outgoing(AgentRequest::TerminalResponse {
1572 request,
1573 response: AgentTerminalResponse::Created { terminal },
1574 });
1575
1576 let (events, _) = t.incoming(Message::Request {
1577 id: 61,
1578 method: "terminal/output".into(),
1579 params: json!({ "sessionId": "s-1", "terminalId": "termesh-9" }),
1580 });
1581 let AgentEvent::TerminalRequest { request, .. } = events[0].clone() else { panic!() };
1582 assert_eq!(
1583 t.outgoing(AgentRequest::TerminalResponse {
1584 request,
1585 response: AgentTerminalResponse::Output {
1586 output: "ok".into(),
1587 truncated: false,
1588 exit: Some(TerminalExit { code: Some(0), signal: None }),
1589 },
1590 }),
1591 [Message::Response {
1592 id: 61,
1593 result: json!({
1594 "output": "ok", "truncated": false,
1595 "exitStatus": { "exitCode": 0, "signal": null }
1596 }),
1597 }]
1598 );
1599
1600 let (events, _) = t.incoming(Message::Request {
1601 id: 62,
1602 method: "terminal/wait_for_exit".into(),
1603 params: json!({ "sessionId": "s-1", "terminalId": "termesh-9" }),
1604 });
1605 let AgentEvent::TerminalRequest { request, .. } = events[0].clone() else { panic!() };
1606 assert_eq!(
1607 t.outgoing(AgentRequest::TerminalResponse {
1608 request,
1609 response: AgentTerminalResponse::Exited(TerminalExit {
1610 code: None,
1611 signal: Some("SIGTERM".into()),
1612 }),
1613 }),
1614 [Message::Response {
1615 id: 62,
1616 result: json!({ "exitCode": null, "signal": "SIGTERM" }),
1617 }]
1618 );
1619
1620 let (events, _) = t.incoming(Message::Request {
1621 id: 63,
1622 method: "terminal/release".into(),
1623 params: json!({ "sessionId": "s-1", "terminalId": "termesh-9" }),
1624 });
1625 let AgentEvent::TerminalRequest { request, .. } = events[0].clone() else { panic!() };
1626 assert_eq!(
1627 t.outgoing(AgentRequest::TerminalResponse {
1628 request,
1629 response: AgentTerminalResponse::Acknowledged,
1630 }),
1631 [Message::Response { id: 63, result: json!({}) }]
1632 );
1633
1634 let (events, replies) = t.incoming(Message::Request {
1635 id: 64,
1636 method: "terminal/output".into(),
1637 params: json!({ "sessionId": "s-1", "terminalId": "termesh-9" }),
1638 });
1639 assert!(events.is_empty());
1640 assert!(matches!(replies.as_slice(), [Message::Error { id: 64, code: -32602, .. }]));
1641
1642 let (events, _) = t.incoming(update(
1643 "s-1",
1644 json!({
1645 "sessionUpdate": "tool_call",
1646 "content": [{ "type": "terminal", "terminalId": "termesh-9" }]
1647 }),
1648 ));
1649 assert_eq!(events, [AgentEvent::TerminalAttached { session, terminal }]);
1650 }
1651
1652 #[test]
1653 fn terminal_create_clamps_output_and_validates_structured_fields() {
1654 let (mut t, _) = connected();
1655 let (events, _) = t.incoming(Message::Request {
1656 id: 70,
1657 method: "terminal/create".into(),
1658 params: json!({
1659 "sessionId": "s-1", "command": "env", "args": ["ok"], "cwd": CWD,
1660 "env": [{ "name": "LANG", "value": "C" }],
1661 "outputByteLimit": 999999999
1662 }),
1663 });
1664 assert!(matches!(events.as_slice(), [AgentEvent::TerminalRequest {
1665 operation: AgentTerminalOperation::Create { spec, output_byte_limit: MAX_OUTPUT_LIMIT, .. },
1666 ..
1667 }] if spec.env == [("LANG".into(), "C".into())]));
1668
1669 for params in [
1670 json!({ "sessionId": "s-1", "command": "x", "cwd": "relative" }),
1671 json!({ "sessionId": "s-1", "command": "x", "cwd": CWD, "args": [1] }),
1672 json!({ "sessionId": "s-1", "command": "x", "cwd": CWD, "env": [{}] }),
1673 ] {
1674 let (events, replies) =
1675 t.incoming(Message::Request { id: 71, method: "terminal/create".into(), params });
1676 assert!(events.is_empty());
1677 assert!(matches!(replies.as_slice(), [Message::Error { code: -32602, .. }]));
1678 }
1679 }
1680
1681 #[test]
1682 fn exact_permission_grant_is_consumed_by_one_matching_create() {
1683 let (mut t, _) = connected();
1684 let (events, _) = t.incoming(Message::Request {
1685 id: 40,
1686 method: "session/request_permission".into(),
1687 params: json!({
1688 "sessionId": "s-1",
1689 "toolCall": { "rawInput": {
1690 "command": ["cargo", "test"], "cwd": CWD, "env": []
1691 }},
1692 "options": [{ "optionId": "yes", "kind": "allow_once" }]
1693 }),
1694 });
1695 let AgentEvent::PermissionRequested { request, terminal_spec, .. } = events[0].clone()
1696 else {
1697 panic!()
1698 };
1699 assert!(terminal_spec.is_some());
1700 let _ = t.outgoing(AgentRequest::Permission {
1701 request,
1702 decision: PermissionDecision::AllowOnce,
1703 });
1704
1705 for expected in [true, false] {
1706 let (events, _) = t.incoming(Message::Request {
1707 id: 41,
1708 method: "terminal/create".into(),
1709 params: json!({
1710 "sessionId": "s-1", "command": "cargo", "args": ["test"],
1711 "cwd": CWD, "env": []
1712 }),
1713 });
1714 assert!(matches!(events.as_slice(), [AgentEvent::TerminalRequest {
1715 operation: termesh_core::AgentTerminalOperation::Create { preauthorized, .. },
1716 ..
1717 }] if *preauthorized == expected));
1718 }
1719 }
1720
1721 #[test]
1726 fn an_unspent_grant_does_not_survive_the_turn_it_was_given_in() {
1727 for end_of_turn in [EndOfTurn::Completed, EndOfTurn::Cancelled, EndOfTurn::Failed] {
1728 let (mut t, session) = connected();
1729 let (events, _) = t.incoming(Message::Request {
1730 id: 40,
1731 method: "session/request_permission".into(),
1732 params: json!({
1733 "sessionId": "s-1",
1734 "toolCall": { "rawInput": {
1735 "command": ["npm", "install"], "cwd": CWD, "env": []
1736 }},
1737 "options": [{ "optionId": "yes", "kind": "allow_once" }]
1738 }),
1739 });
1740 let AgentEvent::PermissionRequested { request, .. } = events[0].clone() else {
1741 panic!("expected a permission request")
1742 };
1743 let _ = t.outgoing(AgentRequest::Permission {
1744 request,
1745 decision: PermissionDecision::AllowOnce,
1746 });
1747
1748 match end_of_turn {
1750 EndOfTurn::Completed => {
1751 let out = t.outgoing(AgentRequest::Prompt {
1752 session,
1753 text: "go".into(),
1754 context: String::new(),
1755 });
1756 let Message::Request { id, .. } = out[0].clone() else {
1757 panic!("prompt should be a request")
1758 };
1759 let _ = t.incoming(Message::Response {
1760 id,
1761 result: json!({ "stopReason": "end_turn" }),
1762 });
1763 }
1764 EndOfTurn::Cancelled => {
1765 let _ = t.outgoing(AgentRequest::Cancel { session });
1766 }
1767 EndOfTurn::Failed => {
1770 let out = t.outgoing(AgentRequest::Prompt {
1771 session,
1772 text: "go".into(),
1773 context: String::new(),
1774 });
1775 let Message::Request { id, .. } = out[0].clone() else {
1776 panic!("prompt should be a request")
1777 };
1778 let _ = t.incoming(Message::Error {
1779 id,
1780 code: -32000,
1781 message: "model unavailable".into(),
1782 });
1783 }
1784 }
1785
1786 let (events, _) = t.incoming(Message::Request {
1787 id: 41,
1788 method: "terminal/create".into(),
1789 params: json!({
1790 "sessionId": "s-1", "command": "npm", "args": ["install"],
1791 "cwd": CWD, "env": []
1792 }),
1793 });
1794 assert!(
1795 matches!(events.as_slice(), [AgentEvent::TerminalRequest {
1796 operation: termesh_core::AgentTerminalOperation::Create { preauthorized, .. },
1797 ..
1798 }] if !*preauthorized),
1799 "{end_of_turn:?}: a stale grant must not preauthorize a launch"
1800 );
1801 }
1802 }
1803
1804 #[derive(Debug, Clone, Copy)]
1805 enum EndOfTurn {
1806 Completed,
1807 Cancelled,
1808 Failed,
1809 }
1810
1811 #[test]
1812 fn malformed_terminal_create_receives_an_error() {
1813 let (mut t, _) = connected();
1814 let (events, replies) = t.incoming(Message::Request {
1815 id: 50,
1816 method: "terminal/create".into(),
1817 params: json!({ "sessionId": "s-1", "command": "", "cwd": "relative" }),
1818 });
1819 assert!(events.is_empty());
1820 assert!(matches!(replies.as_slice(), [Message::Error { id: 50, code: -32602, .. }]));
1821 }
1822
1823 #[test]
1824 fn terminal_ids_are_owned_by_the_session_that_created_them() {
1825 let (mut t, _) = connected();
1826 let (events, _) = t.incoming(Message::Request {
1827 id: 80,
1828 method: "terminal/create".into(),
1829 params: json!({
1830 "sessionId": "s-1", "command": "cargo", "cwd": CWD, "env": []
1831 }),
1832 });
1833 let AgentEvent::TerminalRequest { request, .. } = events[0].clone() else { panic!() };
1834 let _ = t.outgoing(AgentRequest::TerminalResponse {
1835 request,
1836 response: AgentTerminalResponse::Created { terminal: TerminalId::new(12) },
1837 });
1838
1839 let messages = t.outgoing(AgentRequest::NewSession { cwd: "/proj".into() });
1840 let Message::Request { id, .. } = messages[0] else { panic!() };
1841 let _ = t.incoming(Message::Response { id, result: json!({ "sessionId": "s-2" }) });
1842
1843 let (events, replies) = t.incoming(Message::Request {
1844 id: 81,
1845 method: "terminal/output".into(),
1846 params: json!({ "sessionId": "s-2", "terminalId": "termesh-12" }),
1847 });
1848 assert!(events.is_empty());
1849 assert!(matches!(replies.as_slice(), [Message::Error { id: 81, code: -32602, .. }]));
1850 }
1851
1852 #[test]
1855 fn a_prompt_response_ends_the_turn_with_its_reason() {
1856 for (wire, expected) in [
1857 ("end_turn", StopReason::EndTurn),
1858 ("cancelled", StopReason::Cancelled),
1859 ("refusal", StopReason::Refusal),
1860 ("max_tokens", StopReason::MaxTokens),
1861 ] {
1862 let (mut t, session) = connected();
1863 let out = t.outgoing(AgentRequest::Prompt {
1864 session,
1865 text: "go".into(),
1866 context: String::new(),
1867 });
1868 let Message::Request { id, .. } = out[0].clone() else { panic!() };
1869
1870 let (events, _) =
1871 t.incoming(Message::Response { id, result: json!({ "stopReason": wire }) });
1872 assert_eq!(events, vec![AgentEvent::TurnEnded { session, reason: expected }]);
1873 }
1874 }
1875
1876 #[test]
1877 fn an_error_response_to_a_prompt_fails_that_session() {
1878 let (mut t, session) = connected();
1879 let out =
1880 t.outgoing(AgentRequest::Prompt { session, text: "go".into(), context: String::new() });
1881 let Message::Request { id, .. } = out[0].clone() else { panic!() };
1882
1883 let (events, _) =
1884 t.incoming(Message::Error { id, code: -32000, message: "model unavailable".into() });
1885 assert_eq!(
1886 events,
1887 vec![AgentEvent::Failed { session, message: "model unavailable".into() }]
1888 );
1889 }
1890
1891 #[test]
1892 fn cancelling_is_a_notification_not_a_request() {
1893 let (mut t, session) = connected();
1894 let out = t.outgoing(AgentRequest::Cancel { session });
1895 assert!(
1896 matches!(&out[0], Message::Notification { method, .. } if method == "session/cancel"),
1897 "got {out:?}"
1898 );
1899 }
1900
1901 #[test]
1902 fn a_response_we_are_not_waiting_on_is_ignored() {
1903 let (mut t, _) = connected();
1904 let (events, replies) = t.incoming(Message::Response { id: 9999, result: json!({}) });
1905 assert!(events.is_empty() && replies.is_empty());
1906 }
1907}