1use 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#[derive(Debug, Clone, PartialEq, Eq)]
44pub enum ScriptedUpdate {
45 Message(String),
47 Thought(String),
49 ReadFile(PathBuf),
52 Edit { path: PathBuf, old_text: Option<String>, new_text: String },
54 Write { path: PathBuf, content: String },
57 Permission { summary: String, command: Vec<String> },
59 End,
61 Stop(StopReason),
63 Fail(String),
65}
66
67#[derive(Debug, Default)]
69pub struct ScriptedAgent {
70 turns: VecDeque<Vec<ScriptedUpdate>>,
72 resume: Option<Vec<ScriptedUpdate>>,
74 outbox: VecDeque<AgentEvent>,
75 sent: Vec<AgentRequest>,
77 served: Vec<(PathBuf, Option<String>)>,
80 session: Option<SessionId>,
81 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 pub fn with_turn(mut self, updates: Vec<ScriptedUpdate>) -> Self {
97 self.turns.push_back(updates);
98 self
99 }
100
101 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 pub fn with_capabilities(mut self, capabilities: AgentCapabilities) -> Self {
114 self.capabilities = capabilities;
115 self
116 }
117
118 pub fn sent(&self) -> &[AgentRequest] {
120 &self.sent
121 }
122
123 pub fn served(&self) -> &[(PathBuf, Option<String>)] {
125 &self.served
126 }
127
128 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 fn replay(&mut self, mut updates: Vec<ScriptedUpdate>) {
140 let Some(session) = self.session else {
141 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 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 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 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 #[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}