1use std::collections::HashMap;
15use std::path::PathBuf;
16use std::sync::{Arc, Mutex};
17
18use layover_core::agent::AgentName;
19use layover_core::config::Config;
20use layover_core::flight::{Flight, ItineraryId, Origin};
21use layover_core::graph::RouteGraph;
22use layover_core::handover::Handover;
23use layover_core::itinerary::Itinerary;
24use layover_core::layover::Layover;
25use layover_core::learning::{Impact, Learnings, Proposal, Uptake};
26use layover_core::pipeline::PipelineName;
27use layover_core::queue::Queued;
28use layover_mcp::{Peer, Runtime, Session, ToolError};
29
30#[derive(Debug, Default)]
34pub struct Chains {
35 live: Mutex<HashMap<String, Itinerary>>,
36 pipelines: Mutex<HashMap<String, PipelineName>>,
42}
43
44impl Chains {
45 #[must_use]
47 pub fn new() -> Self {
48 Self::default()
49 }
50
51 pub fn with<T>(
57 &self,
58 id: &ItineraryId,
59 defaults: &layover_core::config::Defaults,
60 act: impl FnOnce(&mut Itinerary) -> T,
61 ) -> Option<T> {
62 let mut live = self.live.lock().ok()?;
63 let chain = live.entry(id.as_str().to_owned()).or_insert_with(|| {
64 Itinerary::new(
65 id.clone(),
66 defaults.max_hops,
67 defaults.fuel_usd,
68 defaults.max_runs,
69 )
70 });
71
72 Some(act(chain))
73 }
74
75 #[must_use]
77 pub fn count(&self) -> usize {
78 self.live.lock().map_or(0, |live| live.len())
79 }
80
81 pub fn opened_by(&self, id: &ItineraryId, pipeline: Option<&PipelineName>) {
86 let Some(pipeline) = pipeline else {
87 return;
88 };
89
90 if let Ok(mut known) = self.pipelines.lock() {
91 known
92 .entry(id.as_str().to_owned())
93 .or_insert_with(|| pipeline.clone());
94 }
95 }
96
97 #[must_use]
99 pub fn pipeline_of(&self, id: &ItineraryId) -> Option<PipelineName> {
100 self.pipelines.lock().ok()?.get(id.as_str()).cloned()
101 }
102}
103
104pub type ReadLearnings = Arc<dyn Fn() -> Result<Learnings, String> + Send + Sync>;
110
111pub type WriteLearnings = Arc<dyn Fn(&Learnings) -> Result<(), String> + Send + Sync>;
113
114pub type QueueFlight = Arc<dyn Fn(Queued) -> Result<(), String> + Send + Sync>;
116
117pub type BookLayover = Arc<dyn Fn(Layover) -> Result<(), String> + Send + Sync>;
119
120pub struct FactoryRuntime {
125 config: Arc<Config>,
126 graph: Arc<RouteGraph>,
127 queue: QueueFlight,
128 book: BookLayover,
129 read_learnings: ReadLearnings,
130 write_learnings: WriteLearnings,
131 hangars: PathBuf,
132 logbook: PathBuf,
133}
134
135pub struct Wiring {
141 pub config: Arc<Config>,
143 pub graph: Arc<RouteGraph>,
145 pub hangars: PathBuf,
147 pub logbook: PathBuf,
149 pub queue: QueueFlight,
151 pub book: BookLayover,
153 pub read_learnings: ReadLearnings,
155 pub write_learnings: WriteLearnings,
157}
158
159impl FactoryRuntime {
160 #[must_use]
162 pub fn new(wiring: Wiring) -> Self {
163 Self {
164 config: wiring.config,
165 graph: wiring.graph,
166 queue: wiring.queue,
167 book: wiring.book,
168 read_learnings: wiring.read_learnings,
169 write_learnings: wiring.write_learnings,
170 hangars: wiring.hangars,
171 logbook: wiring.logbook,
172 }
173 }
174}
175
176impl Runtime for FactoryRuntime {
177 fn peers(&self, session: &Session) -> Vec<Peer> {
178 self.graph
179 .successors(&session.agent)
180 .map(|name| Peer {
181 name: name.clone(),
182 description: self
183 .config
184 .agents
185 .get(name)
186 .and_then(|agent| agent.description.clone()),
187 spawns: self.graph.is_spawn(&session.agent, name),
188 })
189 .collect()
190 }
191
192 fn send(&self, session: &Session, to: &AgentName, body: &str) -> Result<String, ToolError> {
193 if !self.config.agents.contains_key(to) {
194 return Err(ToolError::NoSuchAgent { agent: to.clone() });
195 }
196
197 if !self.graph.permits(&session.agent, to) {
198 return Err(ToolError::NotPermitted {
199 from: session.agent.clone(),
200 to: to.clone(),
201 });
202 }
203
204 let spawns = self.graph.is_spawn(&session.agent, to);
208
209 if !spawns && session.hops_remaining == 0 {
213 return Err(ToolError::Refused {
214 because: "this chain has no messages left; finish and report instead of sending"
215 .to_owned(),
216 });
217 }
218
219 let (itinerary, hops) = if spawns {
227 (ItineraryId::generate(), self.config.defaults.max_hops)
228 } else {
229 (session.itinerary.clone(), session.hops_remaining)
230 };
231
232 let flight = Flight::new(
233 itinerary,
234 Origin::Agent(session.agent.clone()),
235 to.clone(),
236 body,
237 hops,
238 );
239 let id = flight.id.as_str().to_owned();
240
241 (self.queue)(Queued::new(flight, None, std::collections::BTreeMap::new()))
242 .map_err(|detail| ToolError::Unavailable { detail })?;
243
244 Ok(id)
245 }
246
247 fn report(&self, session: &Session, headline: &str, body: &str) -> Result<(), ToolError> {
248 let report = layover_core::report::Report::new(
249 session.run.clone(),
250 session.agent.clone(),
251 session.itinerary.clone(),
252 headline,
253 body,
254 jiff::Timestamp::now(),
255 );
256
257 let path = self.agent_dir(&session.agent).join("reports.jsonl");
258 append_json(&path, &report).map_err(|detail| ToolError::Unavailable { detail })
259 }
260
261 fn help(
262 &self,
263 session: &Session,
264 summary: &str,
265 detail: &str,
266 fatal: bool,
267 ) -> Result<(), ToolError> {
268 let mut request = layover_core::help::HelpRequest::new(
269 session.agent.clone(),
270 session.run.clone(),
271 session.itinerary.clone(),
272 layover_core::help::Blocker::Other,
273 summary,
274 detail,
275 jiff::Timestamp::now(),
276 );
277 request.fatal = fatal;
278
279 let path = self.agent_dir(&session.agent).join("help.jsonl");
280 append_json(&path, &request).map_err(|detail| ToolError::Unavailable { detail })
281 }
282
283 fn memory_read(&self, session: &Session) -> Result<String, ToolError> {
284 let path = self.agent_dir(&session.agent).join("memory.md");
285
286 match std::fs::read_to_string(&path) {
287 Ok(text) => Ok(text),
288 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
290 Ok("You have written nothing down yet.".to_owned())
291 }
292 Err(error) => Err(ToolError::Unavailable {
293 detail: error.to_string(),
294 }),
295 }
296 }
297
298 fn memory_write(&self, session: &Session, text: &str) -> Result<(), ToolError> {
299 let dir = self.agent_dir(&session.agent);
300 std::fs::create_dir_all(&dir).map_err(|error| ToolError::Unavailable {
301 detail: error.to_string(),
302 })?;
303
304 let path = dir.join("memory.md");
305 let mut existing = std::fs::read_to_string(&path).unwrap_or_default();
306 if !existing.is_empty() && !existing.ends_with('\n') {
307 existing.push('\n');
308 }
309 existing.push_str(text.trim());
310 existing.push('\n');
311
312 std::fs::write(&path, existing).map_err(|error| ToolError::Unavailable {
313 detail: error.to_string(),
314 })
315 }
316
317 fn wait(&self, session: &Session, until: &str, because: &str) -> Result<String, ToolError> {
318 let wait = parse_wait(until).ok_or_else(|| ToolError::BadArguments {
319 detail: format!(
320 "`{until}` is not a length of time. Use a number and a unit — `30m`, `2h`, `3d` — \
321 which is how long to wait before this is looked at again."
322 ),
323 })?;
324
325 let now = jiff::Timestamp::now();
326 let due_at = now
327 .checked_add(jiff::SignedDuration::from_secs(wait))
328 .map_err(|_| ToolError::BadArguments {
329 detail: format!("`{until}` is further away than this factory can plan for"),
330 })?;
331
332 let handover = Handover::dispatch(Vec::new());
336
337 let layover = Layover::book(
338 session.agent.clone(),
339 session.itinerary.clone(),
340 because,
341 handover,
342 now,
343 due_at,
344 DEFAULT_MAX_CHECKS,
345 );
346
347 let when = layover.due_at.to_string();
348
349 (self.book)(layover).map_err(|detail| ToolError::Unavailable { detail })?;
350
351 Ok(format!(
352 "Set down. This will be picked up no sooner than {when}, by a pipeline that resumes \
353 layovers. Finish and report now — nothing is kept running in the meantime."
354 ))
355 }
356
357 fn learn(&self, session: &Session, text: &str) -> Result<String, ToolError> {
358 let proposal = Proposal::new(
359 session.agent.clone(),
360 text,
361 Impact::Medium,
366 jiff::Timestamp::now(),
367 );
368
369 let mut learnings =
370 (self.read_learnings)().map_err(|detail| ToolError::Unavailable { detail })?;
371
372 let uptake = learnings.propose(&proposal);
373
374 if !matches!(
378 uptake,
379 Uptake::Malformed | Uptake::Refused | Uptake::Echo | Uptake::Unacceptable(_)
380 ) {
381 (self.write_learnings)(&learnings)
382 .map_err(|detail| ToolError::Unavailable { detail })?;
383 }
384
385 Ok(match uptake {
388 Uptake::Taken => "Noted. Future runs of you will be given this until it lapses, and \
389 it becomes permanent if later runs arrive at it independently."
390 .to_owned(),
391 Uptake::Echo => "You were already told this, so repeating it is not evidence of \
392 anything. It stands as it was."
393 .to_owned(),
394 Uptake::Rediscovered { proposals } => format!(
395 "Rediscovered — proposed independently {proposals} time(s) now, so it applies \
396 again and is closer to becoming permanent."
397 ),
398 Uptake::Confirmed => "Rediscovered often enough to be treated as real. It will be \
399 given to future runs indefinitely."
400 .to_owned(),
401 Uptake::Refused => {
402 return Err(ToolError::Refused {
403 because: "a human rejected this, and proposing it again does not reopen it. \
404 If it is genuinely true now, say so in a report."
405 .to_owned(),
406 });
407 }
408 Uptake::Malformed => {
409 return Err(ToolError::BadArguments {
410 detail: "a learning is one or two sentences. Empty text, or more than will \
411 fit in a prompt alongside everything else, is not one."
412 .to_owned(),
413 });
414 }
415 Uptake::Unacceptable(reason) => {
416 return Err(ToolError::Refused {
417 because: reason.to_string(),
418 });
419 }
420 })
421 }
422
423 fn logbook_append(&self, session: &Session, text: &str) -> Result<(), ToolError> {
424 use std::io::Write as _;
425
426 let line = text.trim();
427 if line.is_empty() {
428 return Err(ToolError::BadArguments {
429 detail: "the logbook is read by every agent; an empty entry is noise".to_owned(),
430 });
431 }
432
433 if let Some(parent) = self.logbook.parent() {
434 std::fs::create_dir_all(parent).map_err(|error| ToolError::Unavailable {
435 detail: error.to_string(),
436 })?;
437 }
438
439 let entry = format!(
442 "\n## {} — `{}`\n\n{line}\n",
443 jiff::Timestamp::now(),
444 session.agent
445 );
446
447 std::fs::OpenOptions::new()
448 .create(true)
449 .append(true)
450 .open(&self.logbook)
451 .and_then(|mut file| file.write_all(entry.as_bytes()))
452 .map_err(|error| ToolError::Unavailable {
453 detail: error.to_string(),
454 })
455 }
456}
457
458const DEFAULT_MAX_CHECKS: u32 = 12;
464
465fn parse_wait(text: &str) -> Option<i64> {
470 let trimmed = text.trim();
471 let (digits, unit) = match trimmed.char_indices().next_back() {
472 Some((index, unit)) => (&trimmed[..index], unit),
473 None => return None,
474 };
475
476 let multiplier = match unit {
477 's' => 1_i64,
478 'm' => 60,
479 'h' => 60 * 60,
480 'd' => 24 * 60 * 60,
481 _ => return None,
482 };
483
484 digits.trim().parse::<i64>().ok()?.checked_mul(multiplier)
485}
486
487impl FactoryRuntime {
488 fn agent_dir(&self, agent: &AgentName) -> PathBuf {
490 self.hangars.join(agent.to_string())
491 }
492}
493
494fn append_json<T: serde::Serialize>(path: &std::path::Path, value: &T) -> Result<(), String> {
496 use std::io::Write as _;
497
498 if let Some(parent) = path.parent() {
499 std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
500 }
501
502 let mut line = serde_json::to_string(value).map_err(|error| error.to_string())?;
503 line.push('\n');
504
505 std::fs::OpenOptions::new()
506 .create(true)
507 .append(true)
508 .open(path)
509 .and_then(|mut file| file.write_all(line.as_bytes()))
510 .map_err(|error| error.to_string())
511}
512
513#[cfg(test)]
514mod tests {
515 use super::*;
516 use layover_core::flight::RunId;
517
518 const FACTORY: &str = r#"
519[layover]
520work_dir = "work"
521
522[defaults]
523runner = "shell"
524max_hops = 4
525fuel_usd = 5.0
526max_runs = 10
527
528[runners.shell]
529command = ["echo"]
530
531[agents.analyst]
532description = "Works out what a request means"
533prompt = "analyse"
534entry = true
535
536[agents.developer]
537description = "Writes the code"
538prompt = "develop"
539
540[agents.stranger]
541prompt = "lurk"
542
543[agents.reviewer]
544prompt = "review one pull request"
545
546[pipelines.build]
547entry = "analyst"
548
549[[routes]]
550from = "analyst"
551to = "developer"
552
553[[routes]]
554from = "analyst"
555to = "reviewer"
556mode = "spawn"
557"#;
558
559 struct Fixture {
562 runtime: FactoryRuntime,
563 sent: Arc<Mutex<Vec<Queued>>>,
564 booked: Arc<Mutex<Vec<Layover>>>,
565 learnings: Arc<Mutex<Learnings>>,
566 defaults: layover_core::config::Defaults,
567 dir: PathBuf,
568 }
569
570 impl Fixture {
571 fn new(name: &str) -> Self {
572 let config: Config = toml::from_str(FACTORY).expect("the fixture factory parses");
573 let graph = RouteGraph::from_config(&config);
574 let defaults = config.defaults.clone();
575 let dir =
576 std::env::temp_dir().join(format!("layover-rt-{name}-{}", std::process::id()));
577 let _ = std::fs::remove_dir_all(&dir);
578 std::fs::create_dir_all(&dir).expect("a temporary directory");
579
580 let sent: Arc<Mutex<Vec<Queued>>> = Arc::new(Mutex::new(Vec::new()));
581 let sink = Arc::clone(&sent);
582 let booked: Arc<Mutex<Vec<Layover>>> = Arc::new(Mutex::new(Vec::new()));
583 let shelf = Arc::clone(&booked);
584 let learnings: Arc<Mutex<Learnings>> = Arc::new(Mutex::new(Learnings::new()));
585 let reading = Arc::clone(&learnings);
586 let writing = Arc::clone(&learnings);
587
588 Self {
589 runtime: FactoryRuntime::new(Wiring {
590 config: Arc::new(config),
591 graph: Arc::new(graph),
592 hangars: dir.clone(),
593 logbook: dir.join("logbook.md"),
594 queue: Arc::new(move |queued| {
595 sink.lock().map_err(|_| "poisoned".to_owned())?.push(queued);
596 Ok(())
597 }),
598 book: Arc::new(move |layover| {
599 shelf
600 .lock()
601 .map_err(|_| "poisoned".to_owned())?
602 .push(layover);
603 Ok(())
604 }),
605 read_learnings: Arc::new(move || {
606 Ok(reading.lock().map_err(|_| "poisoned".to_owned())?.clone())
607 }),
608 write_learnings: Arc::new(move |updated| {
609 *writing.lock().map_err(|_| "poisoned".to_owned())? = updated.clone();
610 Ok(())
611 }),
612 }),
613 sent,
614 booked,
615 learnings,
616 defaults,
617 dir,
618 }
619 }
620
621 fn sent(&self) -> Vec<Queued> {
622 self.sent.lock().expect("not poisoned").clone()
623 }
624
625 fn booked(&self) -> Vec<Layover> {
626 self.booked.lock().expect("not poisoned").clone()
627 }
628
629 fn learnings(&self) -> Learnings {
630 self.learnings.lock().expect("not poisoned").clone()
631 }
632 }
633
634 impl Drop for Fixture {
635 fn drop(&mut self) {
636 let _ = std::fs::remove_dir_all(&self.dir);
637 }
638 }
639
640 fn session(agent: &str, hops: u32) -> Session {
641 Session {
642 run: RunId::generate(),
643 agent: AgentName::new(agent),
644 itinerary: ItineraryId::generate(),
645 hops_remaining: hops,
646 }
647 }
648
649 #[test]
650 fn peers_are_what_the_route_map_permits_and_nothing_else() {
651 let fixture = Fixture::new("peers");
652
653 let peers = fixture.runtime.peers(&session("analyst", 3));
654 let names: Vec<String> = peers.iter().map(|peer| peer.name.to_string()).collect();
655
656 assert_eq!(names, ["developer", "reviewer"], "the two drawn edges");
657 assert!(
658 !names.contains(&"stranger".to_owned()),
659 "an agent with no edge from `analyst` is not a peer"
660 );
661 assert_eq!(peers[0].description.as_deref(), Some("Writes the code"));
662 }
663
664 #[test]
665 fn a_sent_flight_continues_the_chain_rather_than_starting_one() {
666 let fixture = Fixture::new("continues");
669 let caller = session("analyst", 3);
670
671 fixture
672 .runtime
673 .send(&caller, &AgentName::new("developer"), "fix it")
674 .expect("the route is drawn");
675
676 let sent = fixture.sent();
677 assert_eq!(sent.len(), 1);
678 assert_eq!(
679 sent[0].flight.itinerary, caller.itinerary,
680 "the flight must belong to the chain that sent it"
681 );
682 assert_eq!(sent[0].flight.hops_remaining, 3);
683 }
684
685 #[test]
686 fn a_sent_flight_records_which_agent_sent_it() {
687 let fixture = Fixture::new("origin");
689
690 fixture
691 .runtime
692 .send(&session("analyst", 2), &AgentName::new("developer"), "go")
693 .expect("the route is drawn");
694
695 assert_eq!(
696 fixture.sent()[0].flight.from,
697 Origin::Agent(AgentName::new("analyst"))
698 );
699 }
700
701 #[test]
702 fn an_edge_the_map_does_not_draw_is_refused_with_advice() {
703 let fixture = Fixture::new("refused");
704
705 let error = fixture
706 .runtime
707 .send(&session("analyst", 3), &AgentName::new("stranger"), "go")
708 .expect_err("no such edge");
709
710 assert!(matches!(error, ToolError::NotPermitted { .. }));
711 assert!(
712 error.to_string().contains("layover_peers"),
713 "a refusal should say how to find out what is permitted: {error}"
714 );
715 assert!(fixture.sent().is_empty(), "nothing may be queued");
716 }
717
718 #[test]
719 fn sending_to_an_agent_that_does_not_exist_says_so() {
720 let fixture = Fixture::new("ghost");
721
722 let error = fixture
723 .runtime
724 .send(&session("analyst", 3), &AgentName::new("ghost"), "go")
725 .expect_err("no such agent");
726
727 assert!(matches!(error, ToolError::NoSuchAgent { .. }), "{error}");
728 }
729
730 #[test]
731 fn a_chain_with_no_hops_left_is_told_to_finish_rather_than_send() {
732 let fixture = Fixture::new("nohops");
735
736 let error = fixture
737 .runtime
738 .send(&session("analyst", 0), &AgentName::new("developer"), "go")
739 .expect_err("out of hops");
740
741 assert!(error.to_string().contains("report"), "{error}");
742 assert!(fixture.sent().is_empty(), "nothing may be queued");
743 }
744
745 #[test]
746 fn a_spawn_edge_opens_a_new_chain_with_its_own_budget() {
747 let fixture = Fixture::new("spawn");
750 let caller = session("analyst", 2);
751
752 fixture
753 .runtime
754 .send(&caller, &AgentName::new("reviewer"), "review #41")
755 .expect("the spawn edge is drawn");
756
757 let sent = fixture.sent();
758 assert_ne!(
759 sent[0].flight.itinerary, caller.itinerary,
760 "a spawn edge starts a chain rather than continuing one"
761 );
762 assert_eq!(
763 sent[0].flight.hops_remaining, fixture.defaults.max_hops,
764 "the new chain gets the configured budget, not the caller's remainder"
765 );
766 }
767
768 #[test]
769 fn a_spawn_may_be_sent_even_when_the_caller_has_no_hops_left() {
770 let fixture = Fixture::new("spawn-nohops");
773
774 let id = fixture
775 .runtime
776 .send(&session("analyst", 0), &AgentName::new("reviewer"), "go")
777 .expect("a spawn does not spend the caller's hops");
778
779 assert!(!id.is_empty());
780 assert_eq!(fixture.sent().len(), 1);
781 }
782
783 #[test]
784 fn a_spawn_edge_is_still_an_edge_the_route_map_has_to_draw() {
785 let fixture = Fixture::new("spawn-refused");
786
787 let error = fixture
788 .runtime
789 .send(&session("developer", 3), &AgentName::new("reviewer"), "go")
790 .expect_err("no edge from developer to reviewer");
791
792 assert!(matches!(error, ToolError::NotPermitted { .. }), "{error}");
793 }
794
795 #[test]
796 fn peers_say_which_of_them_open_a_new_chain() {
797 let fixture = Fixture::new("spawn-peers");
799
800 let peers = fixture.runtime.peers(&session("analyst", 3));
801 let reviewer = peers
802 .iter()
803 .find(|peer| peer.name == AgentName::new("reviewer"))
804 .expect("reviewer is reachable");
805 let developer = peers
806 .iter()
807 .find(|peer| peer.name == AgentName::new("developer"))
808 .expect("developer is reachable");
809
810 assert!(reviewer.spawns, "the spawn edge is marked");
811 assert!(!developer.spawns, "an ordinary edge is not");
812 }
813
814 #[test]
815 fn booking_a_layover_sets_the_work_down_and_says_when_it_returns() {
816 let fixture = Fixture::new("wait");
817
818 let answer = fixture
819 .runtime
820 .wait(&session("analyst", 3), "2h", "the review to land")
821 .expect("2h is a length of time");
822
823 assert!(answer.contains("Set down"), "{answer}");
824 assert!(
825 answer.contains("Finish and report"),
826 "an agent must be told not to wait: {answer}"
827 );
828
829 let booked = fixture.booked();
830 assert_eq!(booked.len(), 1);
831 assert_eq!(booked[0].agent, AgentName::new("analyst"));
832 assert_eq!(booked[0].waiting_for, "the review to land");
833 }
834
835 #[test]
836 fn a_layover_comes_back_to_the_chain_that_booked_it() {
837 let fixture = Fixture::new("wait-chain");
840 let caller = session("analyst", 3);
841
842 fixture
843 .runtime
844 .wait(&caller, "1d", "the build to go green")
845 .expect("books");
846
847 assert_eq!(fixture.booked()[0].booked_by, caller.itinerary);
848 }
849
850 #[test]
851 fn a_layover_is_not_due_before_its_time() {
852 let fixture = Fixture::new("wait-due");
853
854 fixture
855 .runtime
856 .wait(&session("analyst", 3), "2h", "something")
857 .expect("books");
858
859 let booked = &fixture.booked()[0];
860 assert!(!booked.is_due(jiff::Timestamp::now()));
861 assert!(
862 booked.is_due(
863 jiff::Timestamp::now()
864 .checked_add(jiff::SignedDuration::from_hours(3))
865 .expect("in range")
866 )
867 );
868 }
869
870 #[test]
871 fn a_wait_that_is_not_a_length_of_time_is_refused_with_an_example() {
872 let fixture = Fixture::new("wait-bad");
875
876 let error = fixture
877 .runtime
878 .wait(&session("analyst", 3), "when the review lands", "x")
879 .expect_err("not a duration");
880
881 assert!(matches!(error, ToolError::BadArguments { .. }));
882 assert!(error.to_string().contains("2h"), "{error}");
883 assert!(fixture.booked().is_empty(), "nothing may be booked");
884 }
885
886 #[test]
887 fn every_unit_a_schedule_understands_works_here_too() {
888 for (text, seconds) in [("45s", 45), ("30m", 1_800), ("6h", 21_600), ("3d", 259_200)] {
891 assert_eq!(parse_wait(text), Some(seconds), "{text}");
892 }
893
894 assert_eq!(parse_wait("2 weeks"), None);
895 assert_eq!(parse_wait(""), None);
896 }
897
898 #[test]
899 fn a_learning_nobody_has_proposed_before_is_taken_up() {
900 let fixture = Fixture::new("learn");
901
902 let answer = fixture
903 .runtime
904 .learn(&session("analyst", 3), "The e2e suite needs the VPN.")
905 .expect("a first proposal is taken");
906
907 assert!(answer.contains("Noted"), "{answer}");
908 assert_eq!(fixture.learnings().len(), 1);
909 }
910
911 #[test]
912 fn repeating_advice_you_were_already_given_is_not_evidence() {
913 let fixture = Fixture::new("learn-echo");
915 let who = session("analyst", 3);
916
917 fixture
918 .runtime
919 .learn(&who, "The e2e suite needs the VPN.")
920 .expect("taken");
921 let answer = fixture
922 .runtime
923 .learn(&who, "The e2e suite needs the VPN.")
924 .expect("answered");
925
926 assert!(answer.contains("not evidence"), "{answer}");
927 assert_eq!(
928 fixture.learnings().len(),
929 1,
930 "an echo must not become a second learning"
931 );
932 }
933
934 #[test]
935 fn an_empty_learning_is_refused_with_what_one_looks_like() {
936 let fixture = Fixture::new("learn-empty");
937
938 let error = fixture
939 .runtime
940 .learn(&session("analyst", 3), " ")
941 .expect_err("not a learning");
942
943 assert!(matches!(error, ToolError::BadArguments { .. }));
944 assert!(
945 error.to_string().contains("one or two sentences"),
946 "{error}"
947 );
948 }
949
950 #[test]
951 fn a_learning_a_human_rejected_is_not_reopened_by_repetition() {
952 let fixture = Fixture::new("learn-refused");
954 let who = session("analyst", 3);
955
956 fixture
957 .runtime
958 .learn(&who, "Skip the tests.")
959 .expect("taken");
960
961 let id = fixture
962 .learnings()
963 .all()
964 .next()
965 .expect("one learning")
966 .id
967 .clone();
968 {
969 let mut held = fixture.learnings.lock().expect("not poisoned");
970 held.reject(&id, jiff::Timestamp::now());
971 }
972
973 let error = fixture
974 .runtime
975 .learn(&who, "Skip the tests.")
976 .expect_err("rejected stays rejected");
977
978 assert!(matches!(error, ToolError::Refused { .. }));
979 assert!(error.to_string().contains("report"), "{error}");
980 }
981
982 #[test]
983 fn the_logbook_records_who_wrote_each_entry() {
984 let fixture = Fixture::new("logbook");
986
987 fixture
988 .runtime
989 .logbook_append(&session("analyst", 3), "The staging database was rebuilt.")
990 .expect("writes");
991
992 let written = std::fs::read_to_string(fixture.dir.join("logbook.md")).expect("a logbook");
993 assert!(written.contains("analyst"), "{written}");
994 assert!(
995 written.contains("staging database was rebuilt"),
996 "{written}"
997 );
998 }
999
1000 #[test]
1001 fn the_logbook_accumulates_rather_than_replacing() {
1002 let fixture = Fixture::new("logbook-append");
1003 let who = session("analyst", 3);
1004
1005 fixture
1006 .runtime
1007 .logbook_append(&who, "first")
1008 .expect("writes");
1009 fixture
1010 .runtime
1011 .logbook_append(&who, "second")
1012 .expect("writes");
1013
1014 let written = std::fs::read_to_string(fixture.dir.join("logbook.md")).expect("a logbook");
1015 assert!(written.contains("first"), "{written}");
1016 assert!(written.contains("second"), "{written}");
1017 }
1018
1019 #[test]
1020 fn an_empty_logbook_entry_is_refused() {
1021 let fixture = Fixture::new("logbook-empty");
1022
1023 let error = fixture
1024 .runtime
1025 .logbook_append(&session("analyst", 3), " \n ")
1026 .expect_err("noise");
1027
1028 assert!(matches!(error, ToolError::BadArguments { .. }), "{error}");
1029 }
1030
1031 #[test]
1032 fn memory_survives_from_one_run_to_the_next() {
1033 let fixture = Fixture::new("memory");
1034
1035 let first = session("analyst", 3);
1036 fixture
1037 .runtime
1038 .memory_write(&first, "The e2e suite needs the VPN.")
1039 .expect("writes");
1040
1041 let second = session("analyst", 3);
1043 let read = fixture.runtime.memory_read(&second).expect("reads");
1044
1045 assert!(read.contains("needs the VPN"), "{read}");
1046 }
1047
1048 #[test]
1049 fn a_first_run_reading_empty_memory_is_told_so_rather_than_failing() {
1050 let fixture = Fixture::new("firstrun");
1051
1052 let read = fixture
1053 .runtime
1054 .memory_read(&session("analyst", 3))
1055 .expect("an empty memory is not a failure");
1056
1057 assert!(read.contains("nothing"), "{read}");
1058 }
1059
1060 #[test]
1061 fn memory_accumulates_rather_than_replacing() {
1062 let fixture = Fixture::new("accumulate");
1063 let who = session("analyst", 3);
1064
1065 fixture
1066 .runtime
1067 .memory_write(&who, "first thing")
1068 .expect("writes");
1069 fixture
1070 .runtime
1071 .memory_write(&who, "second thing")
1072 .expect("writes");
1073
1074 let read = fixture.runtime.memory_read(&who).expect("reads");
1075 assert!(read.contains("first thing"), "{read}");
1076 assert!(read.contains("second thing"), "{read}");
1077 }
1078
1079 #[test]
1080 fn a_report_is_written_where_it_can_be_found_afterwards() {
1081 let fixture = Fixture::new("report");
1082 let who = session("analyst", 3);
1083
1084 fixture
1085 .runtime
1086 .report(&who, "Found the cause", "It was the cache all along.")
1087 .expect("writes");
1088
1089 let written = std::fs::read_to_string(fixture.dir.join("analyst").join("reports.jsonl"))
1090 .expect("a report file");
1091 assert!(written.contains("Found the cause"), "{written}");
1092 }
1093
1094 #[test]
1095 fn a_chain_is_created_once_and_reused() {
1096 let fixture = Fixture::new("chains");
1097 let chains = Chains::new();
1098 let id = ItineraryId::generate();
1099
1100 chains
1101 .with(&id, &fixture.defaults, |chain| chain.debit_fuel(1.0))
1102 .expect("locks");
1103 let remaining = chains
1104 .with(&id, &fixture.defaults, |chain| chain.fuel_remaining_usd())
1105 .expect("locks");
1106
1107 assert!(
1108 remaining < fixture.defaults.fuel_usd,
1109 "the debit must have persisted across lookups"
1110 );
1111 assert_eq!(chains.count(), 1, "one chain, not two");
1112 }
1113}