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