| 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 |