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, ProposedEditDiff, ReadRequestId, SessionId,
37 SessionMode,
38};
39
40#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum ScriptedUpdate {
46 Message(String),
48 Thought(String),
50 ReadFile(PathBuf),
53 Edit { path: PathBuf, old_text: Option<String>, new_text: String },
55 Write { path: PathBuf, content: String },
58 Permission { summary: String, command: Vec<String> },
60 EditPermission { summary: String, path: PathBuf, old_text: String, new_text: String },
65 End,
67 Stop(StopReason),
69 Fail(String),
71}
72
73#[derive(Debug, Default)]
75pub struct ScriptedAgent {
76 turns: VecDeque<Vec<ScriptedUpdate>>,
78 resume: Option<Vec<ScriptedUpdate>>,
80 outbox: VecDeque<AgentEvent>,
81 sent: Vec<AgentRequest>,
83 served: Vec<(PathBuf, Option<String>)>,
86 session: Option<SessionId>,
87 modes: Vec<SessionMode>,
90 current_mode: Option<String>,
91 next_id: u64,
92 capabilities: AgentCapabilities,
93 ready_emitted: bool,
94}
95
96impl ScriptedAgent {
97 pub fn new() -> Self {
98 Self::default()
99 }
100
101 pub fn with_turn(mut self, updates: Vec<ScriptedUpdate>) -> Self {
103 self.turns.push_back(updates);
104 self
105 }
106
107 pub fn with_modes(mut self, current: &str, modes: Vec<SessionMode>) -> Self {
111 self.current_mode = Some(current.to_string());
112 self.modes = modes;
113 self
114 }
115
116 pub fn with_capabilities(mut self, capabilities: AgentCapabilities) -> Self {
120 self.capabilities = capabilities;
121 self
122 }
123
124 pub fn sent(&self) -> &[AgentRequest] {
126 &self.sent
127 }
128
129 pub fn served(&self) -> &[(PathBuf, Option<String>)] {
131 &self.served
132 }
133
134 pub fn is_exhausted(&self) -> bool {
136 self.turns.is_empty() && self.resume.is_none()
137 }
138
139 fn fresh_id(&mut self) -> u64 {
140 self.next_id += 1;
141 self.next_id
142 }
143
144 fn replay(&mut self, mut updates: Vec<ScriptedUpdate>) {
146 let Some(session) = self.session else {
147 self.outbox.push_back(AgentEvent::Failed {
149 session: SessionId::new(0),
150 message: "prompt before session/new".into(),
151 });
152 return;
153 };
154
155 while !updates.is_empty() {
156 let update = updates.remove(0);
157 match update {
158 ScriptedUpdate::Message(text) => {
159 self.outbox.push_back(AgentEvent::MessageChunk { session, text })
160 }
161 ScriptedUpdate::Thought(text) => {
162 self.outbox.push_back(AgentEvent::ThoughtChunk { session, text })
163 }
164 ScriptedUpdate::ReadFile(path) => {
165 let request = ReadRequestId::new(self.fresh_id());
166 self.outbox.push_back(AgentEvent::ReadFileRequested { session, request, path });
167 self.resume = Some(updates);
170 return;
171 }
172 ScriptedUpdate::Edit { path, old_text, new_text } => {
173 let proposal = ProposalId::new(self.fresh_id());
174 self.outbox.push_back(AgentEvent::ProposedEdit {
175 session,
176 proposal,
177 path,
178 old_text,
179 new_text,
180 });
181 }
182 ScriptedUpdate::Write { path, content } => {
183 let proposal = ProposalId::new(self.fresh_id());
184 self.outbox.push_back(AgentEvent::ProposedEdit {
185 session,
186 proposal,
187 path,
188 old_text: None,
189 new_text: content,
190 });
191 }
192 ScriptedUpdate::Permission { summary, command } => {
193 let request = PermissionRequestId::new(self.fresh_id());
194 self.outbox.push_back(AgentEvent::PermissionRequested {
195 session,
196 request,
197 summary,
198 command,
199 terminal_spec: None,
200 edit: None,
201 });
202 }
203 ScriptedUpdate::EditPermission { summary, path, old_text, new_text } => {
204 let request = PermissionRequestId::new(self.fresh_id());
205 self.outbox.push_back(AgentEvent::PermissionRequested {
206 session,
207 request,
208 summary,
209 command: Vec::new(),
210 terminal_spec: None,
211 edit: Some(ProposedEditDiff { path, old_text, new_text }),
212 });
213 }
214 ScriptedUpdate::End => self
215 .outbox
216 .push_back(AgentEvent::TurnEnded { session, reason: StopReason::EndTurn }),
217 ScriptedUpdate::Stop(reason) => {
218 self.outbox.push_back(AgentEvent::TurnEnded { session, reason })
219 }
220 ScriptedUpdate::Fail(message) => {
221 self.outbox.push_back(AgentEvent::Failed { session, message })
222 }
223 }
224 }
225 }
226}
227
228impl AgentService for ScriptedAgent {
229 fn integration(&self) -> AgentIntegration {
230 AgentIntegration::Acp
231 }
232
233 fn send(&mut self, request: AgentRequest) {
234 self.sent.push(request.clone());
235
236 match request {
237 AgentRequest::NewSession { .. } => {
238 let session = SessionId::new(self.fresh_id());
239 self.session = Some(session);
240 self.outbox.push_back(AgentEvent::SessionStarted { session });
241 if let Some(current) = self.current_mode.clone() {
242 self.outbox.push_back(AgentEvent::ModesAvailable {
243 session,
244 current,
245 available: self.modes.clone(),
246 });
247 }
248 }
249 AgentRequest::Prompt { .. } => {
250 let turn = self.turns.pop_front().unwrap_or_default();
251 self.replay(turn);
252 }
253 AgentRequest::FileContents { path, contents, .. } => {
254 self.served.push((path, contents));
255 if let Some(rest) = self.resume.take() {
256 self.replay(rest);
257 }
258 }
259 AgentRequest::SetMode { session, mode } => {
265 self.current_mode = Some(mode.clone());
266 self.outbox.push_back(AgentEvent::ModeChanged { session, mode });
267 }
268 AgentRequest::Cancel { session } => {
269 self.resume = None;
270 self.outbox
271 .push_back(AgentEvent::TurnEnded { session, reason: StopReason::Cancelled });
272 }
273 AgentRequest::Permission { .. }
274 | AgentRequest::PermissionCancelled { .. }
275 | AgentRequest::TerminalResponse { .. }
276 | AgentRequest::Shutdown => {}
277 }
278 }
279
280 fn poll(&mut self) -> Vec<AgentEvent> {
281 let mut events = Vec::new();
282 if !self.ready_emitted {
283 self.ready_emitted = true;
284 events.push(AgentEvent::Ready { capabilities: self.capabilities });
285 }
286 events.extend(self.outbox.drain(..));
287 events
288 }
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294 use termesh_core::{AgentCapabilities, PromptCapabilities};
295
296 fn started() -> ScriptedAgent {
297 let mut agent = ScriptedAgent::new();
298 agent.send(AgentRequest::NewSession { cwd: PathBuf::from("/proj") });
299 agent
300 }
301
302 fn session_of(agent: &mut ScriptedAgent) -> SessionId {
303 let events = agent.poll();
304 events
305 .iter()
306 .find_map(|event| match event {
307 AgentEvent::SessionStarted { session } => Some(*session),
308 _ => None,
309 })
310 .unwrap_or_else(|| panic!("expected a session, got {events:?}"))
311 }
312
313 fn prompt(agent: &mut ScriptedAgent, session: SessionId) -> Vec<AgentEvent> {
314 agent.send(AgentRequest::Prompt {
315 session,
316 text: "do the thing".into(),
317 context: String::new(),
318 });
319 agent.poll()
320 }
321
322 #[test]
323 fn a_session_starts_before_anything_else_happens() {
324 let mut agent = started();
325 assert!(matches!(
326 agent.poll().as_slice(),
327 [AgentEvent::Ready { .. }, AgentEvent::SessionStarted { .. }]
328 ));
329 }
330
331 #[test]
332 fn negotiated_capabilities_reach_fake_driven_model_tests() {
333 let capabilities = AgentCapabilities {
337 load_session: true,
338 prompt_capabilities: PromptCapabilities {
339 image: true,
340 audio: false,
341 embedded_context: true,
342 },
343 };
344 let mut agent = ScriptedAgent::new().with_capabilities(capabilities);
345 assert!(matches!(
346 agent.poll().as_slice(),
347 [AgentEvent::Ready { capabilities: actual }] if *actual == capabilities
348 ));
349 }
350
351 #[test]
352 fn a_turn_replays_in_order() {
353 let mut agent = ScriptedAgent::new().with_turn(vec![
354 ScriptedUpdate::Thought("thinking".into()),
355 ScriptedUpdate::Message("hello".into()),
356 ScriptedUpdate::End,
357 ]);
358 agent.send(AgentRequest::NewSession { cwd: PathBuf::from("/proj") });
359 let session = session_of(&mut agent);
360
361 let events = prompt(&mut agent, session);
362 assert!(matches!(
363 events.as_slice(),
364 [
365 AgentEvent::ThoughtChunk { .. },
366 AgentEvent::MessageChunk { .. },
367 AgentEvent::TurnEnded { reason: StopReason::EndTurn, .. }
368 ]
369 ));
370 }
371
372 #[test]
375 fn a_read_pauses_the_turn_until_the_client_answers() {
376 let mut agent = ScriptedAgent::new().with_turn(vec![
377 ScriptedUpdate::ReadFile(PathBuf::from("/proj/main.rs")),
378 ScriptedUpdate::Edit {
379 path: PathBuf::from("/proj/main.rs"),
380 old_text: Some("fn main() {}\n".into()),
381 new_text: "fn run() {}\n".into(),
382 },
383 ScriptedUpdate::End,
384 ]);
385 agent.send(AgentRequest::NewSession { cwd: PathBuf::from("/proj") });
386 let session = session_of(&mut agent);
387
388 let events = prompt(&mut agent, session);
389 assert!(
390 matches!(events.as_slice(), [AgentEvent::ReadFileRequested { .. }]),
391 "the turn stops at the read, got {events:?}"
392 );
393
394 agent.send(AgentRequest::FileContents {
395 session,
396 request: ReadRequestId::new(1),
397 path: PathBuf::from("/proj/main.rs"),
398 contents: Some("fn main() {}\n".into()),
399 });
400 let events = agent.poll();
401 assert!(
402 matches!(
403 events.as_slice(),
404 [AgentEvent::ProposedEdit { .. }, AgentEvent::TurnEnded { .. }]
405 ),
406 "and resumes once answered, got {events:?}"
407 );
408 }
409
410 #[test]
411 fn what_the_client_served_is_recorded_for_assertions() {
412 let mut agent = started();
413 let session = session_of(&mut agent);
414 agent.send(AgentRequest::FileContents {
415 session,
416 request: ReadRequestId::new(1),
417 path: PathBuf::from("/proj/a.rs"),
418 contents: Some("live buffer text".into()),
419 });
420
421 assert_eq!(agent.served().len(), 1);
422 assert_eq!(agent.served()[0].1.as_deref(), Some("live buffer text"));
423 }
424
425 #[test]
426 fn proposals_get_distinct_ids() {
427 let mut agent = ScriptedAgent::new().with_turn(vec![
428 ScriptedUpdate::Edit {
429 path: PathBuf::from("/a"),
430 old_text: None,
431 new_text: "a".into(),
432 },
433 ScriptedUpdate::Edit {
434 path: PathBuf::from("/b"),
435 old_text: None,
436 new_text: "b".into(),
437 },
438 ]);
439 agent.send(AgentRequest::NewSession { cwd: PathBuf::from("/proj") });
440 let session = session_of(&mut agent);
441
442 let ids: Vec<ProposalId> = prompt(&mut agent, session)
443 .iter()
444 .filter_map(|e| match e {
445 AgentEvent::ProposedEdit { proposal, .. } => Some(*proposal),
446 _ => None,
447 })
448 .collect();
449 assert_eq!(ids.len(), 2);
450 assert_ne!(ids[0], ids[1]);
451 }
452
453 #[test]
454 fn a_permission_request_carries_an_argv_array() {
455 let mut agent = ScriptedAgent::new().with_turn(vec![ScriptedUpdate::Permission {
456 summary: "run the tests".into(),
457 command: vec!["cargo".into(), "test".into()],
458 }]);
459 agent.send(AgentRequest::NewSession { cwd: PathBuf::from("/proj") });
460 let session = session_of(&mut agent);
461
462 match prompt(&mut agent, session).as_slice() {
463 [AgentEvent::PermissionRequested { command, .. }] => {
464 assert_eq!(command, &["cargo", "test"], "argv, never a shell string");
465 }
466 other => panic!("expected a permission request, got {other:?}"),
467 }
468 }
469
470 #[test]
471 fn cancelling_drops_a_parked_turn() {
472 let mut agent = ScriptedAgent::new().with_turn(vec![
473 ScriptedUpdate::ReadFile(PathBuf::from("/proj/main.rs")),
474 ScriptedUpdate::Message("should never arrive".into()),
475 ]);
476 agent.send(AgentRequest::NewSession { cwd: PathBuf::from("/proj") });
477 let session = session_of(&mut agent);
478 let _ = prompt(&mut agent, session);
479
480 agent.send(AgentRequest::Cancel { session });
481 assert!(matches!(
482 agent.poll().as_slice(),
483 [AgentEvent::TurnEnded { reason: StopReason::Cancelled, .. }]
484 ));
485
486 agent.send(AgentRequest::FileContents {
487 session,
488 request: ReadRequestId::new(1),
489 path: PathBuf::from("/proj/main.rs"),
490 contents: Some("x".into()),
491 });
492 assert!(agent.poll().is_empty(), "a cancelled turn does not resume");
493 }
494
495 #[test]
496 fn prompting_without_a_session_fails_loudly_rather_than_hanging() {
497 let mut agent = ScriptedAgent::new().with_turn(vec![ScriptedUpdate::End]);
498 agent.send(AgentRequest::Prompt {
499 session: SessionId::new(1),
500 text: "hi".into(),
501 context: String::new(),
502 });
503 assert!(matches!(
504 agent.poll().as_slice(),
505 [AgentEvent::Ready { .. }, AgentEvent::Failed { .. }]
506 ));
507 }
508
509 #[test]
510 fn an_exhausted_script_ends_turns_without_producing_anything() {
511 let mut agent = started();
512 let session = session_of(&mut agent);
513 assert!(agent.is_exhausted());
514 assert!(prompt(&mut agent, session).is_empty(), "no script, no events");
515 }
516}