返回 CodeWhale
testing.rs
根目录 / crates / workflow-js / src / testing.rs
1 //! Test support: a scriptable in-memory [`WorkflowDriver`].
2 //!
3 //! [`FakeDriver`] records every [`TaskRequest`] and [`ProgressEvent`] it
4 //! receives, answers spawns from substring-matched reply rules (with optional
5 //! delays for ordering tests), and counts `cancel_all` calls. It exists so
6 //! this crate — and the tui wiring that implements the real driver — can be
7 //! exercised without spawning a single real subagent.
8
9 use std::sync::Mutex;
10 use std::sync::atomic::{AtomicUsize, Ordering};
11 use std::time::Duration;
12
13 use async_trait::async_trait;
14 use tokio::sync::oneshot;
15
16 use crate::driver::{
17 BudgetSnapshot, ProgressEvent, SpawnedTask, TaskCompletion, TaskRequest, WorkflowDriver,
18 };
19 use crate::error::DriverError;
20
21 /// How the fake answers a matched spawn.
22 #[derive(Debug, Clone)]
23 pub enum FakeReply {
24 /// Resolve with this full result text.
25 Complete(String),
26 /// Resolve as a failed subagent.
27 Fail(String),
28 /// Resolve as cancelled.
29 Cancelled,
30 /// Resolve as budget-exhausted mid-flight.
31 BudgetExhausted(String),
32 /// Refuse admission: `spawn_task` returns [`DriverError::Rejected`].
33 Reject(String),
34 /// Admit the task but never complete it (for cancellation tests). The
35 /// completion sender is held so the channel stays open.
36 Never,
37 }
38
39 #[derive(Debug)]
40 struct ReplyRule {
41 needle: String,
42 delay: Option<Duration>,
43 reply: FakeReply,
44 }
45
46 #[derive(Debug, Default)]
47 struct Inner {
48 rules: Vec<ReplyRule>,
49 requests: Vec<TaskRequest>,
50 events: Vec<ProgressEvent>,
51 budget: BudgetSnapshot,
52 spend_per_task: u64,
53 next_id: u64,
54 held: Vec<oneshot::Sender<TaskCompletion>>,
55 }
56
57 /// In-memory [`WorkflowDriver`] with scripted replies.
58 ///
59 /// Unmatched spawns complete immediately with `done:<description>`. Rules are
60 /// matched by substring against the request description, first match wins.
61 #[derive(Debug, Default)]
62 pub struct FakeDriver {
63 inner: Mutex<Inner>,
64 cancel_calls: AtomicUsize,
65 }
66
67 impl FakeDriver {
68 /// A fake with no rules, no budget ceiling, and echo replies.
69 pub fn new() -> Self {
70 Self::default()
71 }
72
73 /// Add a reply rule: requests whose description contains `needle` get
74 /// `reply` immediately.
75 pub fn on(&self, needle: &str, reply: FakeReply) {
76 self.on_with_delay_opt(needle, reply, None);
77 }
78
79 /// Like [`FakeDriver::on`], but the completion is delivered after `delay`
80 /// (the spawn itself still returns immediately).
81 pub fn on_with_delay(&self, needle: &str, reply: FakeReply, delay: Duration) {
82 self.on_with_delay_opt(needle, reply, Some(delay));
83 }
84
85 fn on_with_delay_opt(&self, needle: &str, reply: FakeReply, delay: Option<Duration>) {
86 self.lock().rules.push(ReplyRule {
87 needle: needle.to_string(),
88 delay,
89 reply,
90 });
91 }
92
93 /// Configure the budget pool: ceiling plus a fixed spend debited at each
94 /// spawn (simulating the driver-side reservation of design §5.3).
95 pub fn set_budget(&self, total: Option<u64>, spend_per_task: u64) {
96 let mut inner = self.lock();
97 inner.budget = BudgetSnapshot { total, spent: 0 };
98 inner.spend_per_task = spend_per_task;
99 }
100
101 /// Every request received so far, in spawn order.
102 pub fn requests(&self) -> Vec<TaskRequest> {
103 self.lock().requests.clone()
104 }
105
106 /// Descriptions of every request, in spawn order.
107 pub fn request_descriptions(&self) -> Vec<String> {
108 self.lock()
109 .requests
110 .iter()
111 .map(|request| request.description.clone())
112 .collect()
113 }
114
115 /// Number of admitted spawn calls.
116 pub fn spawn_count(&self) -> usize {
117 self.lock().requests.len()
118 }
119
120 /// Every progress event received so far, in emit order.
121 pub fn events(&self) -> Vec<ProgressEvent> {
122 self.lock().events.clone()
123 }
124
125 /// How many times `cancel_all` has been invoked.
126 pub fn cancel_all_calls(&self) -> usize {
127 self.cancel_calls.load(Ordering::SeqCst)
128 }
129
130 fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
131 self.inner.lock().expect("FakeDriver mutex poisoned")
132 }
133 }
134
135 #[async_trait]
136 impl WorkflowDriver for FakeDriver {
137 async fn spawn_task(&self, request: TaskRequest) -> Result<SpawnedTask, DriverError> {
138 let (task_id, reply, delay) = {
139 let mut inner = self.lock();
140 let matched = inner
141 .rules
142 .iter()
143 .find(|rule| request.description.contains(&rule.needle))
144 .map(|rule| (rule.reply.clone(), rule.delay));
145 let (reply, delay) = matched.unwrap_or_else(|| {
146 (
147 FakeReply::Complete(format!("done:{}", request.description)),
148 None,
149 )
150 });
151 if let FakeReply::Reject(message) = reply {
152 return Err(DriverError::Rejected(message));
153 }
154 inner.requests.push(request);
155 inner.budget.spent += inner.spend_per_task;
156 inner.next_id += 1;
157 (format!("agent_{:04}", inner.next_id), reply, delay)
158 };
159
160 let (tx, rx) = oneshot::channel();
161 match reply {
162 FakeReply::Never => self.lock().held.push(tx),
163 reply => {
164 let completion = match reply {
165 FakeReply::Complete(text) => TaskCompletion::Completed { text },
166 FakeReply::Fail(message) => TaskCompletion::Failed { message },
167 FakeReply::Cancelled => TaskCompletion::Cancelled,
168 FakeReply::BudgetExhausted(message) => {
169 TaskCompletion::BudgetExhausted { message }
170 }
171 FakeReply::Reject(_) | FakeReply::Never => unreachable!("handled above"),
172 };
173 match delay {
174 None => {
175 let _ = tx.send(completion);
176 }
177 Some(delay) => {
178 tokio::spawn(async move {
179 tokio::time::sleep(delay).await;
180 let _ = tx.send(completion);
181 });
182 }
183 }
184 }
185 }
186 Ok(SpawnedTask {
187 task_id,
188 completion: rx,
189 })
190 }
191
192 fn cancel_all(&self) {
193 self.cancel_calls.fetch_add(1, Ordering::SeqCst);
194 }
195
196 fn budget(&self) -> BudgetSnapshot {
197 self.lock().budget
198 }
199
200 fn progress(&self, event: ProgressEvent) {
201 self.lock().events.push(event);
202 }
203 }
204
204 lines RUST