Skip to main content

termesh_test_support/
scripted_agent.rs

1//! A scripted ACP agent — the fake that makes the review loop testable (ADR-0007 §7).
2//!
3//! Lands *before* the real client, because it is what the real client is tested against:
4//! no subprocess, no pipes, no timing, and no network. ARCHITECTURE.md §18 asks for
5//! "scripted ACP agent replaying `session/update` streams incl. edit proposals and
6//! tool-permission requests", and CONTRIBUTING.md's fakes invariant makes it non-optional.
7//!
8//! Scripts are written in terms of what the *agent* does, not in terms of wire messages,
9//! so a test reads like the interaction it is describing:
10//!
11//! ```
12//! use termesh_test_support::{ScriptedAgent, ScriptedUpdate};
13//! use termesh_agent::{AgentRequest, AgentService};
14//!
15//! let mut agent = ScriptedAgent::new().with_turn(vec![
16//!     ScriptedUpdate::Message("Renaming it.".into()),
17//!     ScriptedUpdate::ReadFile("/proj/main.rs".into()),
18//!     ScriptedUpdate::Edit {
19//!         path: "/proj/main.rs".into(),
20//!         old_text: Some("fn main() {}\n".into()),
21//!         new_text: "fn run() {}\n".into(),
22//!     },
23//!     ScriptedUpdate::End,
24//! ]);
25//! agent.send(AgentRequest::NewSession { cwd: "/proj".into() });
26//! assert!(!agent.poll().is_empty());
27//! ```
28
29use std::collections::VecDeque;
30use std::path::PathBuf;
31
32use termesh_agent::service::{
33    AgentEvent, AgentIntegration, AgentRequest, AgentService, StopReason,
34};
35use termesh_core::{
36    AgentCapabilities, PermissionRequestId, ProposalId, ProposedEditDiff, ReadRequestId, SessionId,
37    SessionMode,
38};
39
40/// One thing the scripted agent does, in the order a real turn would do it.
41///
42/// Session and proposal ids are filled in at replay time, so scripts stay readable and
43/// do not have to predict identifiers the client hands out.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum ScriptedUpdate {
46    /// Streamed assistant text.
47    Message(String),
48    /// Streamed reasoning.
49    Thought(String),
50    /// Ask the client for a file. **Replay pauses here** until the client answers, which
51    /// is what makes the read/propose ordering in ADR-0007 §5 testable at all.
52    ReadFile(PathBuf),
53    /// Propose an edit, as whole-file before/after — the shape ACP actually uses.
54    Edit { path: PathBuf, old_text: Option<String>, new_text: String },
55    /// Write a file through the client — what an agent does when it edits. Carries no
56    /// base text, because the client owns the buffer and the agent does not.
57    Write { path: PathBuf, content: String },
58    /// Ask permission to run a command.
59    Permission { summary: String, command: Vec<String> },
60    /// Ask permission to *edit a file*, describing the change — what Codex in `read-only`
61    /// and opencode under `permission.edit: "ask"` do. `old_text` is deliberately free-form:
62    /// pass the whole document to model opencode, or just the touched lines to model Codex,
63    /// because the client has to handle both (ADR-0016 §1a).
64    EditPermission { summary: String, path: PathBuf, old_text: String, new_text: String },
65    /// End the turn normally.
66    End,
67    /// End the turn some other way.
68    Stop(StopReason),
69    /// Fail the turn.
70    Fail(String),
71}
72
73/// An [`AgentService`] that replays a recorded stream.
74#[derive(Debug, Default)]
75pub struct ScriptedAgent {
76    /// One entry per prompt, in order.
77    turns: VecDeque<Vec<ScriptedUpdate>>,
78    /// The remainder of the current turn, parked while we wait for a file.
79    resume: Option<Vec<ScriptedUpdate>>,
80    outbox: VecDeque<AgentEvent>,
81    /// Everything the client sent, for assertions.
82    sent: Vec<AgentRequest>,
83    /// File contents the client served, in order — the evidence that the agent is
84    /// reading *our buffers* rather than the disk.
85    served: Vec<(PathBuf, Option<String>)>,
86    session: Option<SessionId>,
87    /// Modes this agent claims to offer, reported when the session opens. Empty is the
88    /// common case and means the agent has no choice to give (ADR-0015 §4).
89    modes: Vec<SessionMode>,
90    current_mode: Option<String>,
91    next_id: u64,
92    capabilities: AgentCapabilities,
93    ready_emitted: bool,
94}
95
96impl ScriptedAgent {
97    pub fn new() -> Self {
98        Self::default()
99    }
100
101    /// Queue a turn. The first prompt replays the first turn, and so on.
102    pub fn with_turn(mut self, updates: Vec<ScriptedUpdate>) -> Self {
103        self.turns.push_back(updates);
104        self
105    }
106
107    /// Offer session modes, the way Codex does. `current` must name one of them; it is
108    /// the mode the session starts in, and the client is expected to leave it alone until
109    /// a human says otherwise.
110    pub fn with_modes(mut self, current: &str, modes: Vec<SessionMode>) -> Self {
111        self.current_mode = Some(current.to_string());
112        self.modes = modes;
113        self
114    }
115
116    /// Set what the fake connection reports during its handshake. The first poll emits
117    /// `Ready` exactly once, matching the protocol-neutral event the real ACP transport
118    /// produces before any session behavior (ADR-0014 §4).
119    pub fn with_capabilities(mut self, capabilities: AgentCapabilities) -> Self {
120        self.capabilities = capabilities;
121        self
122    }
123
124    /// Every request the client has sent.
125    pub fn sent(&self) -> &[AgentRequest] {
126        &self.sent
127    }
128
129    /// The file contents the client served, in the order they were asked for.
130    pub fn served(&self) -> &[(PathBuf, Option<String>)] {
131        &self.served
132    }
133
134    /// Whether the script has been fully consumed.
135    pub fn is_exhausted(&self) -> bool {
136        self.turns.is_empty() && self.resume.is_none()
137    }
138
139    fn fresh_id(&mut self) -> u64 {
140        self.next_id += 1;
141        self.next_id
142    }
143
144    /// Emit updates until the script ends or asks for a file.
145    fn replay(&mut self, mut updates: Vec<ScriptedUpdate>) {
146        let Some(session) = self.session else {
147            // A prompt with no session is a client bug; surface it rather than hang.
148            self.outbox.push_back(AgentEvent::Failed {
149                session: SessionId::new(0),
150                message: "prompt before session/new".into(),
151            });
152            return;
153        };
154
155        while !updates.is_empty() {
156            let update = updates.remove(0);
157            match update {
158                ScriptedUpdate::Message(text) => {
159                    self.outbox.push_back(AgentEvent::MessageChunk { session, text })
160                }
161                ScriptedUpdate::Thought(text) => {
162                    self.outbox.push_back(AgentEvent::ThoughtChunk { session, text })
163                }
164                ScriptedUpdate::ReadFile(path) => {
165                    let request = ReadRequestId::new(self.fresh_id());
166                    self.outbox.push_back(AgentEvent::ReadFileRequested { session, request, path });
167                    // Park the rest: a real agent cannot propose an edit to a file it has
168                    // not read back yet, and tests should not be able to pretend it can.
169                    self.resume = Some(updates);
170                    return;
171                }
172                ScriptedUpdate::Edit { path, old_text, new_text } => {
173                    let proposal = ProposalId::new(self.fresh_id());
174                    self.outbox.push_back(AgentEvent::ProposedEdit {
175                        session,
176                        proposal,
177                        path,
178                        old_text,
179                        new_text,
180                    });
181                }
182                ScriptedUpdate::Write { path, content } => {
183                    let proposal = ProposalId::new(self.fresh_id());
184                    self.outbox.push_back(AgentEvent::ProposedEdit {
185                        session,
186                        proposal,
187                        path,
188                        old_text: None,
189                        new_text: content,
190                    });
191                }
192                ScriptedUpdate::Permission { summary, command } => {
193                    let request = PermissionRequestId::new(self.fresh_id());
194                    self.outbox.push_back(AgentEvent::PermissionRequested {
195                        session,
196                        request,
197                        summary,
198                        command,
199                        terminal_spec: None,
200                        edit: None,
201                    });
202                }
203                ScriptedUpdate::EditPermission { summary, path, old_text, new_text } => {
204                    let request = PermissionRequestId::new(self.fresh_id());
205                    self.outbox.push_back(AgentEvent::PermissionRequested {
206                        session,
207                        request,
208                        summary,
209                        command: Vec::new(),
210                        terminal_spec: None,
211                        edit: Some(ProposedEditDiff { path, old_text, new_text }),
212                    });
213                }
214                ScriptedUpdate::End => self
215                    .outbox
216                    .push_back(AgentEvent::TurnEnded { session, reason: StopReason::EndTurn }),
217                ScriptedUpdate::Stop(reason) => {
218                    self.outbox.push_back(AgentEvent::TurnEnded { session, reason })
219                }
220                ScriptedUpdate::Fail(message) => {
221                    self.outbox.push_back(AgentEvent::Failed { session, message })
222                }
223            }
224        }
225    }
226}
227
228impl AgentService for ScriptedAgent {
229    fn integration(&self) -> AgentIntegration {
230        AgentIntegration::Acp
231    }
232
233    fn send(&mut self, request: AgentRequest) {
234        self.sent.push(request.clone());
235
236        match request {
237            AgentRequest::NewSession { .. } => {
238                let session = SessionId::new(self.fresh_id());
239                self.session = Some(session);
240                self.outbox.push_back(AgentEvent::SessionStarted { session });
241                if let Some(current) = self.current_mode.clone() {
242                    self.outbox.push_back(AgentEvent::ModesAvailable {
243                        session,
244                        current,
245                        available: self.modes.clone(),
246                    });
247                }
248            }
249            AgentRequest::Prompt { .. } => {
250                let turn = self.turns.pop_front().unwrap_or_default();
251                self.replay(turn);
252            }
253            AgentRequest::FileContents { path, contents, .. } => {
254                self.served.push((path, contents));
255                if let Some(rest) = self.resume.take() {
256                    self.replay(rest);
257                }
258            }
259            // `ModeChanged` is what the real translator emits once the agent has answered —
260            // from the success reply, or from a later `current_mode_update` (ADR-0015 §5).
261            // This double stands in for the translator, so it emits the same event; which
262            // of the two wire paths produced it is settled in the protocol tests, because
263            // real agents differ (codex-acp replies and never notifies).
264            AgentRequest::SetMode { session, mode } => {
265                self.current_mode = Some(mode.clone());
266                self.outbox.push_back(AgentEvent::ModeChanged { session, mode });
267            }
268            AgentRequest::Cancel { session } => {
269                self.resume = None;
270                self.outbox
271                    .push_back(AgentEvent::TurnEnded { session, reason: StopReason::Cancelled });
272            }
273            AgentRequest::Permission { .. }
274            | AgentRequest::PermissionCancelled { .. }
275            | AgentRequest::TerminalResponse { .. }
276            | AgentRequest::Shutdown => {}
277        }
278    }
279
280    fn poll(&mut self) -> Vec<AgentEvent> {
281        let mut events = Vec::new();
282        if !self.ready_emitted {
283            self.ready_emitted = true;
284            events.push(AgentEvent::Ready { capabilities: self.capabilities });
285        }
286        events.extend(self.outbox.drain(..));
287        events
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use termesh_core::{AgentCapabilities, PromptCapabilities};
295
296    fn started() -> ScriptedAgent {
297        let mut agent = ScriptedAgent::new();
298        agent.send(AgentRequest::NewSession { cwd: PathBuf::from("/proj") });
299        agent
300    }
301
302    fn session_of(agent: &mut ScriptedAgent) -> SessionId {
303        let events = agent.poll();
304        events
305            .iter()
306            .find_map(|event| match event {
307                AgentEvent::SessionStarted { session } => Some(*session),
308                _ => None,
309            })
310            .unwrap_or_else(|| panic!("expected a session, got {events:?}"))
311    }
312
313    fn prompt(agent: &mut ScriptedAgent, session: SessionId) -> Vec<AgentEvent> {
314        agent.send(AgentRequest::Prompt {
315            session,
316            text: "do the thing".into(),
317            context: String::new(),
318        });
319        agent.poll()
320    }
321
322    #[test]
323    fn a_session_starts_before_anything_else_happens() {
324        let mut agent = started();
325        assert!(matches!(
326            agent.poll().as_slice(),
327            [AgentEvent::Ready { .. }, AgentEvent::SessionStarted { .. }]
328        ));
329    }
330
331    #[test]
332    fn negotiated_capabilities_reach_fake_driven_model_tests() {
333        // The fake must cross the same protocol-neutral boundary as the real ACP
334        // connection. Otherwise model tests can never exercise ADR-0014's handshake
335        // state without depending on JSON-RPC transport details.
336        let capabilities = AgentCapabilities {
337            load_session: true,
338            prompt_capabilities: PromptCapabilities {
339                image: true,
340                audio: false,
341                embedded_context: true,
342            },
343        };
344        let mut agent = ScriptedAgent::new().with_capabilities(capabilities);
345        assert!(matches!(
346            agent.poll().as_slice(),
347            [AgentEvent::Ready { capabilities: actual }] if *actual == capabilities
348        ));
349    }
350
351    #[test]
352    fn a_turn_replays_in_order() {
353        let mut agent = ScriptedAgent::new().with_turn(vec![
354            ScriptedUpdate::Thought("thinking".into()),
355            ScriptedUpdate::Message("hello".into()),
356            ScriptedUpdate::End,
357        ]);
358        agent.send(AgentRequest::NewSession { cwd: PathBuf::from("/proj") });
359        let session = session_of(&mut agent);
360
361        let events = prompt(&mut agent, session);
362        assert!(matches!(
363            events.as_slice(),
364            [
365                AgentEvent::ThoughtChunk { .. },
366                AgentEvent::MessageChunk { .. },
367                AgentEvent::TurnEnded { reason: StopReason::EndTurn, .. }
368            ]
369        ));
370    }
371
372    /// The ordering ADR-0007 §5 depends on: the agent cannot propose an edit to a file it
373    /// has not read back, so replay parks until the client answers.
374    #[test]
375    fn a_read_pauses_the_turn_until_the_client_answers() {
376        let mut agent = ScriptedAgent::new().with_turn(vec![
377            ScriptedUpdate::ReadFile(PathBuf::from("/proj/main.rs")),
378            ScriptedUpdate::Edit {
379                path: PathBuf::from("/proj/main.rs"),
380                old_text: Some("fn main() {}\n".into()),
381                new_text: "fn run() {}\n".into(),
382            },
383            ScriptedUpdate::End,
384        ]);
385        agent.send(AgentRequest::NewSession { cwd: PathBuf::from("/proj") });
386        let session = session_of(&mut agent);
387
388        let events = prompt(&mut agent, session);
389        assert!(
390            matches!(events.as_slice(), [AgentEvent::ReadFileRequested { .. }]),
391            "the turn stops at the read, got {events:?}"
392        );
393
394        agent.send(AgentRequest::FileContents {
395            session,
396            request: ReadRequestId::new(1),
397            path: PathBuf::from("/proj/main.rs"),
398            contents: Some("fn main() {}\n".into()),
399        });
400        let events = agent.poll();
401        assert!(
402            matches!(
403                events.as_slice(),
404                [AgentEvent::ProposedEdit { .. }, AgentEvent::TurnEnded { .. }]
405            ),
406            "and resumes once answered, got {events:?}"
407        );
408    }
409
410    #[test]
411    fn what_the_client_served_is_recorded_for_assertions() {
412        let mut agent = started();
413        let session = session_of(&mut agent);
414        agent.send(AgentRequest::FileContents {
415            session,
416            request: ReadRequestId::new(1),
417            path: PathBuf::from("/proj/a.rs"),
418            contents: Some("live buffer text".into()),
419        });
420
421        assert_eq!(agent.served().len(), 1);
422        assert_eq!(agent.served()[0].1.as_deref(), Some("live buffer text"));
423    }
424
425    #[test]
426    fn proposals_get_distinct_ids() {
427        let mut agent = ScriptedAgent::new().with_turn(vec![
428            ScriptedUpdate::Edit {
429                path: PathBuf::from("/a"),
430                old_text: None,
431                new_text: "a".into(),
432            },
433            ScriptedUpdate::Edit {
434                path: PathBuf::from("/b"),
435                old_text: None,
436                new_text: "b".into(),
437            },
438        ]);
439        agent.send(AgentRequest::NewSession { cwd: PathBuf::from("/proj") });
440        let session = session_of(&mut agent);
441
442        let ids: Vec<ProposalId> = prompt(&mut agent, session)
443            .iter()
444            .filter_map(|e| match e {
445                AgentEvent::ProposedEdit { proposal, .. } => Some(*proposal),
446                _ => None,
447            })
448            .collect();
449        assert_eq!(ids.len(), 2);
450        assert_ne!(ids[0], ids[1]);
451    }
452
453    #[test]
454    fn a_permission_request_carries_an_argv_array() {
455        let mut agent = ScriptedAgent::new().with_turn(vec![ScriptedUpdate::Permission {
456            summary: "run the tests".into(),
457            command: vec!["cargo".into(), "test".into()],
458        }]);
459        agent.send(AgentRequest::NewSession { cwd: PathBuf::from("/proj") });
460        let session = session_of(&mut agent);
461
462        match prompt(&mut agent, session).as_slice() {
463            [AgentEvent::PermissionRequested { command, .. }] => {
464                assert_eq!(command, &["cargo", "test"], "argv, never a shell string");
465            }
466            other => panic!("expected a permission request, got {other:?}"),
467        }
468    }
469
470    #[test]
471    fn cancelling_drops_a_parked_turn() {
472        let mut agent = ScriptedAgent::new().with_turn(vec![
473            ScriptedUpdate::ReadFile(PathBuf::from("/proj/main.rs")),
474            ScriptedUpdate::Message("should never arrive".into()),
475        ]);
476        agent.send(AgentRequest::NewSession { cwd: PathBuf::from("/proj") });
477        let session = session_of(&mut agent);
478        let _ = prompt(&mut agent, session);
479
480        agent.send(AgentRequest::Cancel { session });
481        assert!(matches!(
482            agent.poll().as_slice(),
483            [AgentEvent::TurnEnded { reason: StopReason::Cancelled, .. }]
484        ));
485
486        agent.send(AgentRequest::FileContents {
487            session,
488            request: ReadRequestId::new(1),
489            path: PathBuf::from("/proj/main.rs"),
490            contents: Some("x".into()),
491        });
492        assert!(agent.poll().is_empty(), "a cancelled turn does not resume");
493    }
494
495    #[test]
496    fn prompting_without_a_session_fails_loudly_rather_than_hanging() {
497        let mut agent = ScriptedAgent::new().with_turn(vec![ScriptedUpdate::End]);
498        agent.send(AgentRequest::Prompt {
499            session: SessionId::new(1),
500            text: "hi".into(),
501            context: String::new(),
502        });
503        assert!(matches!(
504            agent.poll().as_slice(),
505            [AgentEvent::Ready { .. }, AgentEvent::Failed { .. }]
506        ));
507    }
508
509    #[test]
510    fn an_exhausted_script_ends_turns_without_producing_anything() {
511        let mut agent = started();
512        let session = session_of(&mut agent);
513        assert!(agent.is_exhausted());
514        assert!(prompt(&mut agent, session).is_empty(), "no script, no events");
515    }
516}