Skip to main content

termesh_agent/
protocol.rs

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