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