termesh-test-support 0.1.0

Internal component of a terminal-native, agent-first IDE.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
//! A scripted ACP agent — the fake that makes the review loop testable (ADR-0007 §7).
//!
//! Lands *before* the real client, because it is what the real client is tested against:
//! no subprocess, no pipes, no timing, and no network. ARCHITECTURE.md §18 asks for
//! "scripted ACP agent replaying `session/update` streams incl. edit proposals and
//! tool-permission requests", and CONTRIBUTING.md's fakes invariant makes it non-optional.
//!
//! Scripts are written in terms of what the *agent* does, not in terms of wire messages,
//! so a test reads like the interaction it is describing:
//!
//! ```
//! use termesh_test_support::{ScriptedAgent, ScriptedUpdate};
//! use termesh_agent::{AgentRequest, AgentService};
//!
//! let mut agent = ScriptedAgent::new().with_turn(vec![
//!     ScriptedUpdate::Message("Renaming it.".into()),
//!     ScriptedUpdate::ReadFile("/proj/main.rs".into()),
//!     ScriptedUpdate::Edit {
//!         path: "/proj/main.rs".into(),
//!         old_text: Some("fn main() {}\n".into()),
//!         new_text: "fn run() {}\n".into(),
//!     },
//!     ScriptedUpdate::End,
//! ]);
//! agent.send(AgentRequest::NewSession { cwd: "/proj".into() });
//! assert!(!agent.poll().is_empty());
//! ```

use std::collections::VecDeque;
use std::path::PathBuf;

use termesh_agent::service::{
    AgentEvent, AgentIntegration, AgentRequest, AgentService, StopReason,
};
use termesh_core::{AgentCapabilities, PermissionRequestId, ProposalId, ReadRequestId, SessionId};

/// One thing the scripted agent does, in the order a real turn would do it.
///
/// Session and proposal ids are filled in at replay time, so scripts stay readable and
/// do not have to predict identifiers the client hands out.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ScriptedUpdate {
    /// Streamed assistant text.
    Message(String),
    /// Streamed reasoning.
    Thought(String),
    /// Ask the client for a file. **Replay pauses here** until the client answers, which
    /// is what makes the read/propose ordering in ADR-0007 §5 testable at all.
    ReadFile(PathBuf),
    /// Propose an edit, as whole-file before/after — the shape ACP actually uses.
    Edit { path: PathBuf, old_text: Option<String>, new_text: String },
    /// Write a file through the client — what an agent does when it edits. Carries no
    /// base text, because the client owns the buffer and the agent does not.
    Write { path: PathBuf, content: String },
    /// Ask permission to run a command.
    Permission { summary: String, command: Vec<String> },
    /// End the turn normally.
    End,
    /// End the turn some other way.
    Stop(StopReason),
    /// Fail the turn.
    Fail(String),
}

/// An [`AgentService`] that replays a recorded stream.
#[derive(Debug, Default)]
pub struct ScriptedAgent {
    /// One entry per prompt, in order.
    turns: VecDeque<Vec<ScriptedUpdate>>,
    /// The remainder of the current turn, parked while we wait for a file.
    resume: Option<Vec<ScriptedUpdate>>,
    outbox: VecDeque<AgentEvent>,
    /// Everything the client sent, for assertions.
    sent: Vec<AgentRequest>,
    /// File contents the client served, in order — the evidence that the agent is
    /// reading *our buffers* rather than the disk.
    served: Vec<(PathBuf, Option<String>)>,
    session: Option<SessionId>,
    next_id: u64,
    capabilities: AgentCapabilities,
    ready_emitted: bool,
}

impl ScriptedAgent {
    pub fn new() -> Self {
        Self::default()
    }

    /// Queue a turn. The first prompt replays the first turn, and so on.
    pub fn with_turn(mut self, updates: Vec<ScriptedUpdate>) -> Self {
        self.turns.push_back(updates);
        self
    }

    /// Set what the fake connection reports during its handshake. The first poll emits
    /// `Ready` exactly once, matching the protocol-neutral event the real ACP transport
    /// produces before any session behavior (ADR-0014 §4).
    pub fn with_capabilities(mut self, capabilities: AgentCapabilities) -> Self {
        self.capabilities = capabilities;
        self
    }

    /// Every request the client has sent.
    pub fn sent(&self) -> &[AgentRequest] {
        &self.sent
    }

    /// The file contents the client served, in the order they were asked for.
    pub fn served(&self) -> &[(PathBuf, Option<String>)] {
        &self.served
    }

    /// Whether the script has been fully consumed.
    pub fn is_exhausted(&self) -> bool {
        self.turns.is_empty() && self.resume.is_none()
    }

    fn fresh_id(&mut self) -> u64 {
        self.next_id += 1;
        self.next_id
    }

    /// Emit updates until the script ends or asks for a file.
    fn replay(&mut self, mut updates: Vec<ScriptedUpdate>) {
        let Some(session) = self.session else {
            // A prompt with no session is a client bug; surface it rather than hang.
            self.outbox.push_back(AgentEvent::Failed {
                session: SessionId::new(0),
                message: "prompt before session/new".into(),
            });
            return;
        };

        while !updates.is_empty() {
            let update = updates.remove(0);
            match update {
                ScriptedUpdate::Message(text) => {
                    self.outbox.push_back(AgentEvent::MessageChunk { session, text })
                }
                ScriptedUpdate::Thought(text) => {
                    self.outbox.push_back(AgentEvent::ThoughtChunk { session, text })
                }
                ScriptedUpdate::ReadFile(path) => {
                    let request = ReadRequestId::new(self.fresh_id());
                    self.outbox.push_back(AgentEvent::ReadFileRequested { session, request, path });
                    // Park the rest: a real agent cannot propose an edit to a file it has
                    // not read back yet, and tests should not be able to pretend it can.
                    self.resume = Some(updates);
                    return;
                }
                ScriptedUpdate::Edit { path, old_text, new_text } => {
                    let proposal = ProposalId::new(self.fresh_id());
                    self.outbox.push_back(AgentEvent::ProposedEdit {
                        session,
                        proposal,
                        path,
                        old_text,
                        new_text,
                    });
                }
                ScriptedUpdate::Write { path, content } => {
                    let proposal = ProposalId::new(self.fresh_id());
                    self.outbox.push_back(AgentEvent::ProposedEdit {
                        session,
                        proposal,
                        path,
                        old_text: None,
                        new_text: content,
                    });
                }
                ScriptedUpdate::Permission { summary, command } => {
                    let request = PermissionRequestId::new(self.fresh_id());
                    self.outbox.push_back(AgentEvent::PermissionRequested {
                        session,
                        request,
                        summary,
                        command,
                        terminal_spec: None,
                    });
                }
                ScriptedUpdate::End => self
                    .outbox
                    .push_back(AgentEvent::TurnEnded { session, reason: StopReason::EndTurn }),
                ScriptedUpdate::Stop(reason) => {
                    self.outbox.push_back(AgentEvent::TurnEnded { session, reason })
                }
                ScriptedUpdate::Fail(message) => {
                    self.outbox.push_back(AgentEvent::Failed { session, message })
                }
            }
        }
    }
}

impl AgentService for ScriptedAgent {
    fn integration(&self) -> AgentIntegration {
        AgentIntegration::Acp
    }

    fn send(&mut self, request: AgentRequest) {
        self.sent.push(request.clone());

        match request {
            AgentRequest::NewSession { .. } => {
                let session = SessionId::new(self.fresh_id());
                self.session = Some(session);
                self.outbox.push_back(AgentEvent::SessionStarted { session });
            }
            AgentRequest::Prompt { .. } => {
                let turn = self.turns.pop_front().unwrap_or_default();
                self.replay(turn);
            }
            AgentRequest::FileContents { path, contents, .. } => {
                self.served.push((path, contents));
                if let Some(rest) = self.resume.take() {
                    self.replay(rest);
                }
            }
            AgentRequest::Cancel { session } => {
                self.resume = None;
                self.outbox
                    .push_back(AgentEvent::TurnEnded { session, reason: StopReason::Cancelled });
            }
            AgentRequest::Permission { .. }
            | AgentRequest::PermissionCancelled { .. }
            | AgentRequest::TerminalResponse { .. }
            | AgentRequest::Shutdown => {}
        }
    }

    fn poll(&mut self) -> Vec<AgentEvent> {
        let mut events = Vec::new();
        if !self.ready_emitted {
            self.ready_emitted = true;
            events.push(AgentEvent::Ready { capabilities: self.capabilities });
        }
        events.extend(self.outbox.drain(..));
        events
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use termesh_core::{AgentCapabilities, PromptCapabilities};

    fn started() -> ScriptedAgent {
        let mut agent = ScriptedAgent::new();
        agent.send(AgentRequest::NewSession { cwd: PathBuf::from("/proj") });
        agent
    }

    fn session_of(agent: &mut ScriptedAgent) -> SessionId {
        let events = agent.poll();
        events
            .iter()
            .find_map(|event| match event {
                AgentEvent::SessionStarted { session } => Some(*session),
                _ => None,
            })
            .unwrap_or_else(|| panic!("expected a session, got {events:?}"))
    }

    fn prompt(agent: &mut ScriptedAgent, session: SessionId) -> Vec<AgentEvent> {
        agent.send(AgentRequest::Prompt {
            session,
            text: "do the thing".into(),
            context: String::new(),
        });
        agent.poll()
    }

    #[test]
    fn a_session_starts_before_anything_else_happens() {
        let mut agent = started();
        assert!(matches!(
            agent.poll().as_slice(),
            [AgentEvent::Ready { .. }, AgentEvent::SessionStarted { .. }]
        ));
    }

    #[test]
    fn negotiated_capabilities_reach_fake_driven_model_tests() {
        // The fake must cross the same protocol-neutral boundary as the real ACP
        // connection. Otherwise model tests can never exercise ADR-0014's handshake
        // state without depending on JSON-RPC transport details.
        let capabilities = AgentCapabilities {
            load_session: true,
            prompt_capabilities: PromptCapabilities {
                image: true,
                audio: false,
                embedded_context: true,
            },
        };
        let mut agent = ScriptedAgent::new().with_capabilities(capabilities);
        assert!(matches!(
            agent.poll().as_slice(),
            [AgentEvent::Ready { capabilities: actual }] if *actual == capabilities
        ));
    }

    #[test]
    fn a_turn_replays_in_order() {
        let mut agent = ScriptedAgent::new().with_turn(vec![
            ScriptedUpdate::Thought("thinking".into()),
            ScriptedUpdate::Message("hello".into()),
            ScriptedUpdate::End,
        ]);
        agent.send(AgentRequest::NewSession { cwd: PathBuf::from("/proj") });
        let session = session_of(&mut agent);

        let events = prompt(&mut agent, session);
        assert!(matches!(
            events.as_slice(),
            [
                AgentEvent::ThoughtChunk { .. },
                AgentEvent::MessageChunk { .. },
                AgentEvent::TurnEnded { reason: StopReason::EndTurn, .. }
            ]
        ));
    }

    /// The ordering ADR-0007 §5 depends on: the agent cannot propose an edit to a file it
    /// has not read back, so replay parks until the client answers.
    #[test]
    fn a_read_pauses_the_turn_until_the_client_answers() {
        let mut agent = ScriptedAgent::new().with_turn(vec![
            ScriptedUpdate::ReadFile(PathBuf::from("/proj/main.rs")),
            ScriptedUpdate::Edit {
                path: PathBuf::from("/proj/main.rs"),
                old_text: Some("fn main() {}\n".into()),
                new_text: "fn run() {}\n".into(),
            },
            ScriptedUpdate::End,
        ]);
        agent.send(AgentRequest::NewSession { cwd: PathBuf::from("/proj") });
        let session = session_of(&mut agent);

        let events = prompt(&mut agent, session);
        assert!(
            matches!(events.as_slice(), [AgentEvent::ReadFileRequested { .. }]),
            "the turn stops at the read, got {events:?}"
        );

        agent.send(AgentRequest::FileContents {
            session,
            request: ReadRequestId::new(1),
            path: PathBuf::from("/proj/main.rs"),
            contents: Some("fn main() {}\n".into()),
        });
        let events = agent.poll();
        assert!(
            matches!(
                events.as_slice(),
                [AgentEvent::ProposedEdit { .. }, AgentEvent::TurnEnded { .. }]
            ),
            "and resumes once answered, got {events:?}"
        );
    }

    #[test]
    fn what_the_client_served_is_recorded_for_assertions() {
        let mut agent = started();
        let session = session_of(&mut agent);
        agent.send(AgentRequest::FileContents {
            session,
            request: ReadRequestId::new(1),
            path: PathBuf::from("/proj/a.rs"),
            contents: Some("live buffer text".into()),
        });

        assert_eq!(agent.served().len(), 1);
        assert_eq!(agent.served()[0].1.as_deref(), Some("live buffer text"));
    }

    #[test]
    fn proposals_get_distinct_ids() {
        let mut agent = ScriptedAgent::new().with_turn(vec![
            ScriptedUpdate::Edit {
                path: PathBuf::from("/a"),
                old_text: None,
                new_text: "a".into(),
            },
            ScriptedUpdate::Edit {
                path: PathBuf::from("/b"),
                old_text: None,
                new_text: "b".into(),
            },
        ]);
        agent.send(AgentRequest::NewSession { cwd: PathBuf::from("/proj") });
        let session = session_of(&mut agent);

        let ids: Vec<ProposalId> = prompt(&mut agent, session)
            .iter()
            .filter_map(|e| match e {
                AgentEvent::ProposedEdit { proposal, .. } => Some(*proposal),
                _ => None,
            })
            .collect();
        assert_eq!(ids.len(), 2);
        assert_ne!(ids[0], ids[1]);
    }

    #[test]
    fn a_permission_request_carries_an_argv_array() {
        let mut agent = ScriptedAgent::new().with_turn(vec![ScriptedUpdate::Permission {
            summary: "run the tests".into(),
            command: vec!["cargo".into(), "test".into()],
        }]);
        agent.send(AgentRequest::NewSession { cwd: PathBuf::from("/proj") });
        let session = session_of(&mut agent);

        match prompt(&mut agent, session).as_slice() {
            [AgentEvent::PermissionRequested { command, .. }] => {
                assert_eq!(command, &["cargo", "test"], "argv, never a shell string");
            }
            other => panic!("expected a permission request, got {other:?}"),
        }
    }

    #[test]
    fn cancelling_drops_a_parked_turn() {
        let mut agent = ScriptedAgent::new().with_turn(vec![
            ScriptedUpdate::ReadFile(PathBuf::from("/proj/main.rs")),
            ScriptedUpdate::Message("should never arrive".into()),
        ]);
        agent.send(AgentRequest::NewSession { cwd: PathBuf::from("/proj") });
        let session = session_of(&mut agent);
        let _ = prompt(&mut agent, session);

        agent.send(AgentRequest::Cancel { session });
        assert!(matches!(
            agent.poll().as_slice(),
            [AgentEvent::TurnEnded { reason: StopReason::Cancelled, .. }]
        ));

        agent.send(AgentRequest::FileContents {
            session,
            request: ReadRequestId::new(1),
            path: PathBuf::from("/proj/main.rs"),
            contents: Some("x".into()),
        });
        assert!(agent.poll().is_empty(), "a cancelled turn does not resume");
    }

    #[test]
    fn prompting_without_a_session_fails_loudly_rather_than_hanging() {
        let mut agent = ScriptedAgent::new().with_turn(vec![ScriptedUpdate::End]);
        agent.send(AgentRequest::Prompt {
            session: SessionId::new(1),
            text: "hi".into(),
            context: String::new(),
        });
        assert!(matches!(
            agent.poll().as_slice(),
            [AgentEvent::Ready { .. }, AgentEvent::Failed { .. }]
        ));
    }

    #[test]
    fn an_exhausted_script_ends_turns_without_producing_anything() {
        let mut agent = started();
        let session = session_of(&mut agent);
        assert!(agent.is_exhausted());
        assert!(prompt(&mut agent, session).is_empty(), "no script, no events");
    }
}