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