返回 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 /// The driver seam is gone: `spawn_task` returns
35 /// [`DriverError::Unavailable`].
36 Unavailable(String),
37 /// Admit the task, then drop the completion sender without ever sending a
38 /// terminal outcome — the "driver dropped the completion channel" path.
39 DropCompletion,
40 /// Admit the task but never complete it (for cancellation tests). The
41 /// completion sender is held so the channel stays open.
42 Never,
43 /// Never finish admission: `spawn_task` itself stays pending, like a
44 /// saturated concurrency gate (for cancellation tests). The request is
45 /// still recorded, so `spawn_count` shows the task reached admission.
46 HoldAdmission,
47 }
48
49 #[derive(Debug)]
50 struct ReplyRule {
51 needle: String,
52 delay: Option<Duration>,
53 reply: FakeReply,
54 }
55
56 #[derive(Debug, Default)]
57 struct Inner {
58 rules: Vec<ReplyRule>,
59 requests: Vec<TaskRequest>,
60 events: Vec<ProgressEvent>,
61 budget: BudgetSnapshot,
62 spend_per_task: u64,
63 next_id: u64,
64 held: Vec<oneshot::Sender<TaskCompletion>>,
65 }
66
67 /// In-memory [`WorkflowDriver`] with scripted replies.
68 ///
69 /// Unmatched spawns complete immediately with `done:<description>`. Rules are
70 /// matched by substring against the request description, first match wins.
71 #[derive(Debug, Default)]
72 pub struct FakeDriver {
73 inner: Mutex<Inner>,
74 cancel_calls: AtomicUsize,
75 }
76
77 impl FakeDriver {
78 /// A fake with no rules, no budget ceiling, and echo replies.
79 pub fn new() -> Self {
80 Self::default()
81 }
82
83 /// Add a reply rule: requests whose description contains `needle` get
84 /// `reply` immediately.
85 pub fn on(&self, needle: &str, reply: FakeReply) {
86 self.on_with_delay_opt(needle, reply, None);
87 }
88
89 /// Like [`FakeDriver::on`], but the completion is delivered after `delay`
90 /// (the spawn itself still returns immediately).
91 pub fn on_with_delay(&self, needle: &str, reply: FakeReply, delay: Duration) {
92 self.on_with_delay_opt(needle, reply, Some(delay));
93 }
94
95 fn on_with_delay_opt(&self, needle: &str, reply: FakeReply, delay: Option<Duration>) {
96 self.lock().rules.push(ReplyRule {
97 needle: needle.to_string(),
98 delay,
99 reply,
100 });
101 }
102
103 /// Configure the budget pool: ceiling plus a fixed spend debited at each
104 /// spawn (simulating the driver-side reservation of design §5.3).
105 pub fn set_budget(&self, total: Option<u64>, spend_per_task: u64) {
106 let mut inner = self.lock();
107 inner.budget = BudgetSnapshot { total, spent: 0 };
108 inner.spend_per_task = spend_per_task;
109 }
110
111 /// Every request received so far, in spawn order.
112 pub fn requests(&self) -> Vec<TaskRequest> {
113 self.lock().requests.clone()
114 }
115
116 /// Descriptions of every request, in spawn order.
117 pub fn request_descriptions(&self) -> Vec<String> {
118 self.lock()
119 .requests
120 .iter()
121 .map(|request| request.description.clone())
122 .collect()
123 }
124
125 /// Number of admitted spawn calls.
126 pub fn spawn_count(&self) -> usize {
127 self.lock().requests.len()
128 }
129
130 /// Every progress event received so far, in emit order.
131 pub fn events(&self) -> Vec<ProgressEvent> {
132 self.lock().events.clone()
133 }
134
135 /// How many times `cancel_all` has been invoked.
136 pub fn cancel_all_calls(&self) -> usize {
137 self.cancel_calls.load(Ordering::SeqCst)
138 }
139
140 fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
141 self.inner.lock().expect("FakeDriver mutex poisoned")
142 }
143 }
144
145 #[async_trait]
146 impl WorkflowDriver for FakeDriver {
147 async fn spawn_task(&self, request: TaskRequest) -> Result<SpawnedTask, DriverError> {
148 let (task_id, reply, delay) = {
149 let mut inner = self.lock();
150 let matched = inner
151 .rules
152 .iter()
153 .find(|rule| request.description.contains(&rule.needle))
154 .map(|rule| (rule.reply.clone(), rule.delay));
155 let (reply, delay) = matched.unwrap_or_else(|| {
156 (
157 FakeReply::Complete(format!("done:{}", request.description)),
158 None,
159 )
160 });
161 match reply {
162 FakeReply::Reject(message) => return Err(DriverError::Rejected(message)),
163 FakeReply::Unavailable(message) => {
164 return Err(DriverError::Unavailable(message));
165 }
166 _ => {}
167 }
168 inner.requests.push(request);
169 inner.budget.spent += inner.spend_per_task;
170 inner.next_id += 1;
171 (format!("agent_{:04}", inner.next_id), reply, delay)
172 };
173 if matches!(reply, FakeReply::HoldAdmission) {
174 return std::future::pending().await;
175 }
176
177 let (tx, rx) = oneshot::channel();
178 match reply {
179 FakeReply::Never => self.lock().held.push(tx),
180 FakeReply::DropCompletion => drop(tx),
181 reply => {
182 let completion = match reply {
183 FakeReply::Complete(text) => TaskCompletion::Completed { text },
184 FakeReply::Fail(message) => TaskCompletion::Failed { message },
185 FakeReply::Cancelled => TaskCompletion::Cancelled,
186 FakeReply::BudgetExhausted(message) => {
187 TaskCompletion::BudgetExhausted { message }
188 }
189 FakeReply::Reject(_)
190 | FakeReply::Unavailable(_)
191 | FakeReply::Never
192 | FakeReply::HoldAdmission
193 | FakeReply::DropCompletion => unreachable!("handled above"),
194 };
195 match delay {
196 None => {
197 let _ = tx.send(completion);
198 }
199 Some(delay) => {
200 tokio::spawn(async move {
201 tokio::time::sleep(delay).await;
202 let _ = tx.send(completion);
203 });
204 }
205 }
206 }
207 }
208 Ok(SpawnedTask {
209 task_id,
210 completion: rx,
211 })
212 }
213
214 fn cancel_all(&self) {
215 self.cancel_calls.fetch_add(1, Ordering::SeqCst);
216 }
217
218 fn budget(&self) -> BudgetSnapshot {
219 self.lock().budget
220 }
221
222 fn progress(&self, event: ProgressEvent) {
223 self.lock().events.push(event);
224 }
225 }
226
226 lines RUST