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