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