返回 CodeWhale
queued_approval_tests.rs
根目录 / crates / tui / src / core / queued_approval_tests.rs
1 //! E2 regression (DOGFOOD-DESKTOP-20260922, app #97): approving one call
2 //! must never cancel the calls queued behind it.
3 //!
4 //! The dogfood report showed an approval that failed the call it approved
5 //! and then cancelled the queued calls after it (`request was cancelled
6 //! before this tool ran`). `59c2a1668` fixed the posture re-check for a
7 //! single call; this pins the queue: one model step emits three gated shell
8 //! calls, the second and third wait behind the first approval card, and the
9 //! client republishes its unchanged posture as the desktop app does on every
10 //! approval. Every call must run, in order, and none may come back
11 //! cancelled.
12
13 use std::time::Duration;
14
15 use codewhale_config::AppMode;
16 use codewhale_execpolicy::ApprovalMode;
17 use tempfile::tempdir;
18
19 use crate::compaction::CompactionConfig;
20 use crate::config::Config;
21 use crate::core::engine::{Engine, EngineConfig};
22 use crate::core::events::Event;
23 use crate::core::ops::{Op, TurnSpec, UserInputProvenance};
24 use crate::test_support::lock_test_env;
25
26 const CALLS: [&str; 3] = ["call_e2_first", "call_e2_second", "call_e2_third"];
27
28 fn event_timeout() -> Duration {
29 // The Windows runner shares CPU with the whole TUI test binary; an
30 // approval-gated turn must not be mistaken for a lifecycle failure.
31 if cfg!(windows) {
32 Duration::from_secs(60)
33 } else {
34 Duration::from_secs(10)
35 }
36 }
37
38 /// One SSE response whose single step carries three gated `Bash` calls, each
39 /// writing its own marker file.
40 fn three_gated_calls_sse() -> String {
41 let mut sse = String::new();
42 for (index, id) in CALLS.iter().enumerate() {
43 let arguments = format!(
44 "{{\\\"action\\\":\\\"run\\\",\\\"command\\\":\\\"echo {index} > {id}.txt\\\"}}"
45 );
46 sse.push_str(&format!(
47 "data: {{\"id\":\"chatcmpl-e2q\",\"choices\":[{{\"index\":0,\"delta\":{{\"tool_calls\":[\
48 {{\"index\":{index},\"id\":\"{id}\",\"type\":\"function\",\"function\":{{\"name\":\"Bash\",\
49 \"arguments\":\"{arguments}\"}}}}]}},\"finish_reason\":null}}]}}\n\n"
50 ));
51 }
52 sse.push_str(concat!(
53 "data: {\"id\":\"chatcmpl-e2q\",\"choices\":[{\"index\":0,\"delta\":{},",
54 "\"finish_reason\":\"tool_calls\"}]}\n\n",
55 "data: [DONE]\n\n",
56 ));
57 sse
58 }
59
60 #[tokio::test]
61 #[allow(clippy::await_holding_lock)]
62 async fn approving_the_first_of_three_queued_calls_cancels_none_of_them() {
63 use wiremock::matchers::{body_string_contains, method, path};
64 use wiremock::{Mock, MockServer, ResponseTemplate};
65
66 let _lock = lock_test_env();
67 let workspace = tempdir().expect("tempdir");
68 let server = MockServer::start().await;
69 let done_sse = concat!(
70 "data: {\"id\":\"chatcmpl-e2q-done\",\"choices\":[{\"index\":0,",
71 "\"delta\":{\"content\":\"done\"},\"finish_reason\":null}]}\n\n",
72 "data: {\"id\":\"chatcmpl-e2q-done\",\"choices\":[{\"index\":0,\"delta\":{},",
73 "\"finish_reason\":\"stop\"}]}\n\n",
74 "data: [DONE]\n\n",
75 );
76 // The follow-up request carries the third call's result.
77 Mock::given(method("POST"))
78 .and(path("/v1/chat/completions"))
79 .and(body_string_contains(CALLS[2]))
80 .respond_with(
81 ResponseTemplate::new(200)
82 .insert_header("content-type", "text/event-stream")
83 .set_body_string(done_sse),
84 )
85 .with_priority(1)
86 .mount(&server)
87 .await;
88 Mock::given(method("POST"))
89 .and(path("/v1/chat/completions"))
90 .respond_with(
91 ResponseTemplate::new(200)
92 .insert_header("content-type", "text/event-stream")
93 .set_body_string(three_gated_calls_sse()),
94 )
95 .expect(1)
96 .with_priority(2)
97 .mount(&server)
98 .await;
99
100 let api_config = Config {
101 ..Config::default()
102 }
103 .with_legacy_root(Some("test-key".to_string()), Some(server.uri()));
104 let route = crate::route_runtime::resolve_runtime_route(
105 &api_config,
106 api_config.active_provider_identity().unwrap().provider,
107 Some(crate::config::DEFAULT_TEXT_MODEL),
108 )
109 .expect("resolve test route");
110 let (engine, handle) = Engine::new(
111 EngineConfig {
112 model: crate::config::DEFAULT_TEXT_MODEL.to_string(),
113 workspace: workspace.path().to_path_buf(),
114 snapshots_enabled: false,
115 subagents_enabled: false,
116 terminal_chrome_enabled: false,
117 ..EngineConfig::default()
118 },
119 &api_config,
120 );
121 let run_task = tokio::spawn(engine.run());
122 handle
123 .send(Op::SendMessage(TurnSpec {
124 max_output_tokens: None,
125 content: "Record three approval fixtures in the workspace".to_string(),
126 images: Vec::new(),
127 mode: AppMode::Agent,
128 route: Box::new(route),
129 compaction: Box::new(CompactionConfig::default()),
130 initial_routed_usage: Box::default(),
131 goal_objective: None,
132 goal_token_budget: None,
133 goal_status: crate::tools::goal::GoalStatus::Active,
134 reasoning_effort: None,
135 reasoning_effort_auto: false,
136 auto_model: false,
137 allow_shell: true,
138 trust_mode: false,
139 auto_approve: false,
140 approval_mode: ApprovalMode::Suggest,
141 translation_enabled: false,
142 allowed_tools: None,
143 dynamic_tools: Vec::new(),
144 hook_executor: None,
145 verbosity: None,
146 provenance: UserInputProvenance::ExternalUser,
147 submission_id: None,
148 }))
149 .await
150 .expect("send model turn");
151
152 let mut approvals = 0usize;
153 let mut starts = Vec::new();
154 let mut results = Vec::new();
155 let mut rx = handle.rx_event.write().await;
156 while let Some(event) = tokio::time::timeout(event_timeout(), rx.recv())
157 .await
158 .expect("timed out waiting for turn event")
159 {
160 match event {
161 Event::ToolCallStarted {
162 id,
163 name,
164 model_call,
165 ..
166 } if name == "Bash" => {
167 let model_call = model_call.expect("model call correlation");
168 uuid::Uuid::parse_str(&id).expect("host execution id");
169 assert!(!CALLS.contains(&id.as_str()));
170 assert!(!starts.iter().any(|(started, _)| started == &id));
171 starts.push((id, model_call.provider_id));
172 }
173 Event::ApprovalRequired { id, .. } => {
174 assert!(starts.iter().any(|(started, _)| started == &id));
175 assert!(!CALLS.contains(&id.as_str()));
176 approvals += 1;
177 if approvals == 1 {
178 // The desktop client republishes its unchanged posture
179 // alongside the first approval; with two calls queued
180 // behind this card, that must invalidate nothing.
181 handle
182 .try_send(Op::ChangeMode {
183 mode: AppMode::Agent,
184 allow_shell: true,
185 trust_mode: false,
186 auto_approve: false,
187 approval_mode: ApprovalMode::Suggest,
188 configured_sandbox_mode: None,
189 })
190 .expect("republish posture");
191 }
192 handle.approve_tool_call(id).await.expect("approve call");
193 }
194 Event::ToolCallComplete {
195 id,
196 name,
197 result,
198 model_call,
199 } if name == "Bash" => {
200 let model_call = model_call.expect("model call correlation");
201 assert!(starts.iter().any(
202 |(started, provider)| started == &id && provider == &model_call.provider_id
203 ));
204 results.push((id, model_call.provider_id, result));
205 }
206 Event::TurnComplete { .. } => break,
207 _ => {}
208 }
209 }
210 drop(rx);
211 handle.send(Op::Shutdown).await.expect("shutdown engine");
212 run_task.await.expect("engine task");
213
214 assert!(
215 approvals >= 1,
216 "the first call waited on an approval card, with the others queued behind it"
217 );
218 assert_eq!(
219 results.len(),
220 CALLS.len(),
221 "every queued call reported a result: {results:?}"
222 );
223 for (id, _, result) in &results {
224 let result = result
225 .as_ref()
226 .unwrap_or_else(|err| panic!("{id} failed after an approval: {err}"));
227 assert!(result.success, "{id}: {result:?}");
228 let content = result.content.to_ascii_lowercase();
229 assert!(
230 !content.contains("cancelled") && !content.contains("canceled"),
231 "{id} was cancelled by an approval: {}",
232 result.content
233 );
234 }
235 let order: Vec<&str> = results
236 .iter()
237 .map(|(_, provider, _)| provider.as_str())
238 .collect();
239 assert_eq!(order, CALLS, "queued calls run in the order the model gave");
240 assert_eq!(
241 starts
242 .iter()
243 .map(|(_, provider)| provider.as_str())
244 .collect::<Vec<_>>(),
245 CALLS,
246 "admitted calls retain the model's order and provider identities"
247 );
248 assert_eq!(
249 results.iter().map(|(id, _, _)| id).collect::<Vec<_>>(),
250 starts.iter().map(|(id, _)| id).collect::<Vec<_>>(),
251 "each admitted execution completes exactly once in queue order"
252 );
253 for id in CALLS {
254 assert!(
255 workspace.path().join(format!("{id}.txt")).exists(),
256 "{id} ran: its marker file exists"
257 );
258 }
259 }
260
260 lines RUST