Skip to main content

termesh_agent/
protocol.rs

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