1use 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#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum ScriptedUpdate {
43 Message(String),
45 Thought(String),
47 ReadFile(PathBuf),
50 Edit { path: PathBuf, old_text: Option<String>, new_text: String },
52 Write { path: PathBuf, content: String },
55 Permission { summary: String, command: Vec<String> },
57 End,
59 Stop(StopReason),
61 Fail(String),
63}
64
65#[derive(Debug, Default)]
67pub struct ScriptedAgent {
68 turns: VecDeque<Vec<ScriptedUpdate>>,
70 resume: Option<Vec<ScriptedUpdate>>,
72 outbox: VecDeque<AgentEvent>,
73 sent: Vec<AgentRequest>,
75 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 pub fn with_turn(mut self, updates: Vec<ScriptedUpdate>) -> Self {
91 self.turns.push_back(updates);
92 self
93 }
94
95 pub fn with_capabilities(mut self, capabilities: AgentCapabilities) -> Self {
99 self.capabilities = capabilities;
100 self
101 }
102
103 pub fn sent(&self) -> &[AgentRequest] {
105 &self.sent
106 }
107
108 pub fn served(&self) -> &[(PathBuf, Option<String>)] {
110 &self.served
111 }
112
113 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 fn replay(&mut self, mut updates: Vec<ScriptedUpdate>) {
125 let Some(session) = self.session else {
126 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 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 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 #[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}