| 1 | //! #5769: a terminal partial SSE loss must not strand the next manual turn. |
| 2 | //! These use the real HTTP client and one EngineHandle throughout, with no |
| 3 | //! approval, restart, SyncSession, or synthetic continuation between turns. |
| 4 | |
| 5 | use super::*; |
| 6 | use serde_json::{Value, json}; |
| 7 | use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; |
| 8 | use tokio::sync::Notify; |
| 9 | |
| 10 | const FIRST_USER: &str = "FIRST_REAL_USER"; |
| 11 | const SECOND_USER: &str = "SECOND_REAL_USER"; |
| 12 | const NEXT_ANSWER: &str = "NEXT_TURN_COMPLETED"; |
| 13 | const SESSION_ID: &str = "sse-recovery-same-session"; |
| 14 | const CONTROL_TIMEOUT: Duration = Duration::from_secs(5); |
| 15 | const FIXTURE_INPUT_TOKENS: u32 = 13; |
| 16 | const FIXTURE_OUTPUT_TOKENS: u32 = 5; |
| 17 | |
| 18 | #[derive(Clone, Copy)] |
| 19 | enum Failure { |
| 20 | TruncatedBody, |
| 21 | StalledBody, |
| 22 | } |
| 23 | |
| 24 | struct LoopbackSse { |
| 25 | base_url: String, |
| 26 | requests: Arc<StdMutex<Vec<Value>>>, |
| 27 | stalled_connection_closed: Arc<Notify>, |
| 28 | task: tokio::task::JoinHandle<()>, |
| 29 | } |
| 30 | |
| 31 | impl Drop for LoopbackSse { |
| 32 | fn drop(&mut self) { |
| 33 | // The server owns its connection tasks through JoinSet: aborting it |
| 34 | // also closes any still-held response, including assertion failures. |
| 35 | self.task.abort(); |
| 36 | } |
| 37 | } |
| 38 | |
| 39 | impl LoopbackSse { |
| 40 | async fn start(failure: Failure) -> Self { |
| 41 | let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
| 42 | let base_url = format!("http://{}/v1", listener.local_addr().unwrap()); |
| 43 | let requests = Arc::new(StdMutex::new(Vec::new())); |
| 44 | let captured = Arc::clone(&requests); |
| 45 | let stalled_connection_closed = Arc::new(Notify::new()); |
| 46 | let closed = Arc::clone(&stalled_connection_closed); |
| 47 | let task = tokio::spawn(async move { |
| 48 | let mut connections = tokio::task::JoinSet::new(); |
| 49 | loop { |
| 50 | tokio::select! { |
| 51 | accepted = listener.accept() => { |
| 52 | let (socket, _) = accepted.unwrap(); |
| 53 | let captured = Arc::clone(&captured); |
| 54 | let closed = Arc::clone(&closed); |
| 55 | connections.spawn(async move { |
| 56 | serve_response(socket, failure, captured, closed).await; |
| 57 | }); |
| 58 | } |
| 59 | completed = connections.join_next(), if !connections.is_empty() => { |
| 60 | completed.unwrap().expect("loopback connection task"); |
| 61 | } |
| 62 | } |
| 63 | } |
| 64 | }); |
| 65 | Self { |
| 66 | base_url, |
| 67 | requests, |
| 68 | stalled_connection_closed, |
| 69 | task, |
| 70 | } |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | async fn serve_response( |
| 75 | socket: tokio::net::TcpStream, |
| 76 | failure: Failure, |
| 77 | captured: Arc<StdMutex<Vec<Value>>>, |
| 78 | closed: Arc<Notify>, |
| 79 | ) { |
| 80 | let mut reader = BufReader::new(socket); |
| 81 | let mut line = String::new(); |
| 82 | reader.read_line(&mut line).await.unwrap(); |
| 83 | assert_eq!(line.trim(), "POST /v1/chat/completions HTTP/1.1"); |
| 84 | let mut content_length = None; |
| 85 | loop { |
| 86 | line.clear(); |
| 87 | assert!(reader.read_line(&mut line).await.unwrap() > 0); |
| 88 | if line == "\r\n" { |
| 89 | break; |
| 90 | } |
| 91 | if let Some((name, value)) = line.split_once(':') |
| 92 | && name.eq_ignore_ascii_case("content-length") |
| 93 | { |
| 94 | content_length = Some(value.trim().parse::<usize>().unwrap()); |
| 95 | } |
| 96 | } |
| 97 | let length = content_length.expect("request body length"); |
| 98 | assert!(length <= 1024 * 1024); |
| 99 | let mut body = vec![0; length]; |
| 100 | reader.read_exact(&mut body).await.unwrap(); |
| 101 | let request: Value = serde_json::from_slice(&body).unwrap(); |
| 102 | let is_second_turn = request["messages"] |
| 103 | .as_array() |
| 104 | .unwrap() |
| 105 | .iter() |
| 106 | .any(|message| { |
| 107 | message["role"] == "user" && message["content"].to_string().contains(SECOND_USER) |
| 108 | }); |
| 109 | let call = { |
| 110 | let mut requests = captured.lock().unwrap(); |
| 111 | requests.push(request.clone()); |
| 112 | requests.len() |
| 113 | }; |
| 114 | let text = if is_second_turn { |
| 115 | NEXT_ANSWER.to_string() |
| 116 | } else { |
| 117 | format!("partial-{call}") |
| 118 | }; |
| 119 | let frame = json!({ |
| 120 | "id": "loopback-sse", "object": "chat.completion.chunk", "model": request["model"], |
| 121 | "choices": [{"index": 0, "delta": {"content": text}, |
| 122 | "finish_reason": if is_second_turn { Some("stop") } else { None }}] |
| 123 | }); |
| 124 | let response = if is_second_turn { |
| 125 | // OpenAI-compatible streaming usage arrives in a final choices-empty |
| 126 | // frame. Keep the fixture token-only so it proves event separation |
| 127 | // without retaining user prompt text anywhere beyond the test request. |
| 128 | let usage = json!({ |
| 129 | "id": "loopback-sse", "object": "chat.completion.chunk", "model": request["model"], |
| 130 | "choices": [], |
| 131 | "usage": { |
| 132 | "prompt_tokens": FIXTURE_INPUT_TOKENS, |
| 133 | "completion_tokens": FIXTURE_OUTPUT_TOKENS, |
| 134 | }, |
| 135 | }); |
| 136 | format!("data: {frame}\n\ndata: {usage}\n\ndata: [DONE]\n\n") |
| 137 | } else { |
| 138 | format!("data: {frame}\n\n") |
| 139 | }; |
| 140 | // Declaring an unmet length makes a socket close a real reqwest decode |
| 141 | // error, rather than a clean EOF or a canned ModelClient error string. |
| 142 | let declared_length = if is_second_turn { |
| 143 | response.len() |
| 144 | } else { |
| 145 | 1024 * 1024 |
| 146 | }; |
| 147 | let mut socket = reader.into_inner(); |
| 148 | socket.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {declared_length}\r\nConnection: close\r\n\r\n{response}").as_bytes()).await.unwrap(); |
| 149 | if !is_second_turn && matches!(failure, Failure::StalledBody) { |
| 150 | // Do not close or expire the response: cancellation must release the |
| 151 | // client connection before any production stream timeout elapses. |
| 152 | let mut byte = [0]; |
| 153 | assert_eq!(socket.read(&mut byte).await.unwrap(), 0); |
| 154 | closed.notify_one(); |
| 155 | } else { |
| 156 | socket.shutdown().await.unwrap(); |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | async fn next_event(handle: &EngineHandle) -> Event { |
| 161 | handle |
| 162 | .rx_event |
| 163 | .write() |
| 164 | .await |
| 165 | .recv() |
| 166 | .await |
| 167 | .expect("engine event stream remains open") |
| 168 | } |
| 169 | |
| 170 | async fn finish_turn(handle: &EngineHandle, events: &mut Vec<Event>) { |
| 171 | tokio::time::timeout(model_turn_event_timeout(), async { |
| 172 | loop { |
| 173 | let event = next_event(handle).await; |
| 174 | let terminal = matches!(event, Event::TurnComplete { .. }); |
| 175 | events.push(event); |
| 176 | if terminal { |
| 177 | break; |
| 178 | } |
| 179 | } |
| 180 | }) |
| 181 | .await |
| 182 | .expect("same engine must settle the turn"); |
| 183 | } |
| 184 | |
| 185 | async fn send_user(handle: &EngineHandle, config: &Config, content: &str) { |
| 186 | let mut op = external_user_message_op(content, AppMode::Agent, config); |
| 187 | if let Op::SendMessage(TurnSpec { allow_shell, .. }) = &mut op { |
| 188 | *allow_shell = false; |
| 189 | } |
| 190 | tokio::time::timeout(CONTROL_TIMEOUT, handle.send(op)) |
| 191 | .await |
| 192 | .expect("same engine mailbox must accept the next user turn") |
| 193 | .unwrap(); |
| 194 | } |
| 195 | |
| 196 | fn terminal_status(events: &[Event]) -> TurnOutcomeStatus { |
| 197 | match events.last().unwrap() { |
| 198 | Event::TurnComplete { status, .. } => *status, |
| 199 | event => panic!("expected terminal TurnComplete, got {event:?}"), |
| 200 | } |
| 201 | } |
| 202 | |
| 203 | fn terminal_diagnostics(events: &[Event]) -> &crate::tool_inspection::TurnStopDiagnostics { |
| 204 | events |
| 205 | .iter() |
| 206 | .find_map(|event| match event { |
| 207 | Event::ToolRequestSnapshot { snapshot } => snapshot.terminal.as_ref(), |
| 208 | _ => None, |
| 209 | }) |
| 210 | .expect("terminal request diagnostics") |
| 211 | } |
| 212 | |
| 213 | fn retry_status_count(events: &[Event]) -> usize { |
| 214 | events |
| 215 | .iter() |
| 216 | .filter(|event| matches!(event, Event::Status { message } if message == "Reconnecting…")) |
| 217 | .count() |
| 218 | } |
| 219 | |
| 220 | async fn verify_next_user_turn_after_loss(failure: Failure) { |
| 221 | let server = LoopbackSse::start(failure).await; |
| 222 | let workspace = tempdir().unwrap(); |
| 223 | let config = Config { |
| 224 | provider: Some("custom".to_string()), |
| 225 | api_key: Some("synthetic-loopback-key".to_string()), |
| 226 | base_url: Some(server.base_url.clone()), |
| 227 | default_text_model: Some(crate::config::DEFAULT_TEXT_MODEL.to_string()), |
| 228 | ..Config::default() |
| 229 | }; |
| 230 | let (engine, handle) = Engine::new( |
| 231 | EngineConfig { |
| 232 | max_steps: 1, |
| 233 | terminal_chrome_enabled: true, |
| 234 | session_id: Some(SESSION_ID.to_string()), |
| 235 | ..deterministic_engine_config(workspace.path()) |
| 236 | }, |
| 237 | &config, |
| 238 | ); |
| 239 | let task = tokio::spawn(engine.run()); |
| 240 | send_user(&handle, &config, FIRST_USER).await; |
| 241 | let mut first = Vec::new(); |
| 242 | if matches!(failure, Failure::StalledBody) { |
| 243 | tokio::time::timeout(model_turn_event_timeout(), async { |
| 244 | loop { |
| 245 | let event = next_event(&handle).await; |
| 246 | let partial = |
| 247 | matches!(&event, Event::MessageDelta { content, .. } if content == "partial-1"); |
| 248 | first.push(event); |
| 249 | if partial { |
| 250 | break; |
| 251 | } |
| 252 | } |
| 253 | }) |
| 254 | .await |
| 255 | .expect("the stalled stream must deliver its partial response"); |
| 256 | handle.cancel(); |
| 257 | tokio::time::timeout(CONTROL_TIMEOUT, finish_turn(&handle, &mut first)) |
| 258 | .await |
| 259 | .expect("cancel must interrupt an open response without waiting for its idle timeout"); |
| 260 | tokio::time::timeout(CONTROL_TIMEOUT, server.stalled_connection_closed.notified()) |
| 261 | .await |
| 262 | .expect("cancel must release the HTTP response connection"); |
| 263 | assert_eq!(terminal_status(&first), TurnOutcomeStatus::Interrupted); |
| 264 | } else { |
| 265 | finish_turn(&handle, &mut first).await; |
| 266 | assert_eq!(terminal_status(&first), TurnOutcomeStatus::Failed); |
| 267 | assert!( |
| 268 | first |
| 269 | .iter() |
| 270 | .any(|event| matches!(event, Event::Error { envelope, .. } |
| 271 | if envelope.category == crate::error_taxonomy::ErrorCategory::Network |
| 272 | && envelope.message.contains("error decoding response body"))) |
| 273 | ); |
| 274 | } |
| 275 | let partial_count = match failure { |
| 276 | Failure::TruncatedBody => usize::try_from(super::super::MAX_STREAM_RETRIES).unwrap() + 1, |
| 277 | Failure::StalledBody => 1, |
| 278 | }; |
| 279 | assert_eq!( |
| 280 | server.requests.lock().unwrap().len(), |
| 281 | partial_count, |
| 282 | "the failed/cancelled turn must settle before the new user turn; no healthy same-turn retry" |
| 283 | ); |
| 284 | let first_terminal = terminal_diagnostics(&first); |
| 285 | assert_eq!( |
| 286 | usize::try_from(first_terminal.model_requests_started).unwrap(), |
| 287 | partial_count, |
| 288 | "terminal parent-request count must match POSTs observed by the loopback" |
| 289 | ); |
| 290 | let expected_resumes = match failure { |
| 291 | Failure::TruncatedBody => super::super::MAX_STREAM_RETRIES, |
| 292 | Failure::StalledBody => 0, |
| 293 | }; |
| 294 | assert_eq!(first_terminal.stream_resumes, expected_resumes); |
| 295 | assert_eq!(first_terminal.transparent_stream_retries, 0); |
| 296 | assert_eq!( |
| 297 | retry_status_count(&first), |
| 298 | usize::from(expected_resumes >= 2), |
| 299 | "multiple retries share one progress notice; diagnostics still count every resume" |
| 300 | ); |
| 301 | assert!( |
| 302 | !first |
| 303 | .iter() |
| 304 | .any(|event| matches!(event, Event::TurnUsage { .. })), |
| 305 | "a stream without a provider usage frame must not fabricate token usage" |
| 306 | ); |
| 307 | |
| 308 | // Submit the NEXT real user message immediately after terminal settlement, |
| 309 | // using the original handle. There is no reconstruction or --continue. |
| 310 | send_user(&handle, &config, SECOND_USER).await; |
| 311 | let mut second = Vec::new(); |
| 312 | finish_turn(&handle, &mut second).await; |
| 313 | assert_eq!(terminal_status(&second), TurnOutcomeStatus::Completed); |
| 314 | assert!(matches!( |
| 315 | second.last(), |
| 316 | Some(Event::TurnComplete { error: None, .. }) |
| 317 | )); |
| 318 | assert!(second.iter().any( |
| 319 | |event| matches!(event, Event::MessageDelta { content, .. } if content == NEXT_ANSWER) |
| 320 | )); |
| 321 | let second_terminal = terminal_diagnostics(&second); |
| 322 | assert_eq!(second_terminal.model_requests_started, 1); |
| 323 | assert_eq!(second_terminal.stream_resumes, 0); |
| 324 | assert_eq!(second_terminal.transparent_stream_retries, 0); |
| 325 | assert_eq!(retry_status_count(&second), 0); |
| 326 | let usage_receipts = second |
| 327 | .iter() |
| 328 | .filter_map(|event| match event { |
| 329 | Event::TurnUsage { usage, .. } => Some(usage), |
| 330 | _ => None, |
| 331 | }) |
| 332 | .collect::<Vec<_>>(); |
| 333 | assert_eq!(usage_receipts.len(), 1); |
| 334 | assert_eq!(usage_receipts[0].input_tokens, FIXTURE_INPUT_TOKENS); |
| 335 | assert_eq!(usage_receipts[0].output_tokens, FIXTURE_OUTPUT_TOKENS); |
| 336 | |
| 337 | for event in first.iter().chain(&second) { |
| 338 | assert!( |
| 339 | !matches!( |
| 340 | event, |
| 341 | Event::ApprovalRequired { .. } |
| 342 | | Event::ElevationRequired { .. } |
| 343 | | Event::UserInputRequired { .. } |
| 344 | ), |
| 345 | "this regression has no pending approval or user-input gate: {event:?}" |
| 346 | ); |
| 347 | if let Event::SessionUpdated { session_id, .. } = event { |
| 348 | assert_eq!(session_id, SESSION_ID); |
| 349 | } |
| 350 | } |
| 351 | let turn_ids: Vec<_> = first |
| 352 | .iter() |
| 353 | .chain(&second) |
| 354 | .filter_map(|event| match event { |
| 355 | Event::TurnStarted { turn_id, .. } => Some(turn_id), |
| 356 | _ => None, |
| 357 | }) |
| 358 | .collect(); |
| 359 | assert_eq!(turn_ids.len(), 2); |
| 360 | assert_ne!( |
| 361 | turn_ids[0], turn_ids[1], |
| 362 | "second completion must be a distinct user turn" |
| 363 | ); |
| 364 | let requests = server.requests.lock().unwrap().clone(); |
| 365 | assert_eq!(requests.len(), partial_count + 1); |
| 366 | assert_eq!( |
| 367 | requests.len() - partial_count, |
| 368 | 1, |
| 369 | "the clean second user turn must issue exactly one loopback POST" |
| 370 | ); |
| 371 | let replay = &requests.last().unwrap()["messages"]; |
| 372 | let replay_text = replay.to_string(); |
| 373 | for fragment in [FIRST_USER.to_string(), SECOND_USER.to_string()] |
| 374 | .into_iter() |
| 375 | .chain((1..=partial_count).map(|index| format!("partial-{index}"))) |
| 376 | { |
| 377 | assert_eq!( |
| 378 | replay_text.matches(&fragment).count(), |
| 379 | 1, |
| 380 | "missing or duplicated history fragment {fragment}: {replay}" |
| 381 | ); |
| 382 | } |
| 383 | assert_eq!( |
| 384 | replay |
| 385 | .as_array() |
| 386 | .unwrap() |
| 387 | .iter() |
| 388 | .filter(|message| message["role"] == "user") |
| 389 | .count(), |
| 390 | 2 |
| 391 | ); |
| 392 | |
| 393 | let (tx, rx) = tokio::sync::oneshot::channel(); |
| 394 | handle |
| 395 | .send(Op::GetSessionSnapshot { |
| 396 | tx: Arc::new(StdMutex::new(Some(tx))), |
| 397 | }) |
| 398 | .await |
| 399 | .unwrap(); |
| 400 | let snapshot = tokio::time::timeout(CONTROL_TIMEOUT, rx) |
| 401 | .await |
| 402 | .expect("session snapshot stays responsive") |
| 403 | .unwrap(); |
| 404 | let persisted = serde_json::to_string(&snapshot.messages).unwrap(); |
| 405 | for fragment in [FIRST_USER, SECOND_USER, NEXT_ANSWER] { |
| 406 | assert_eq!(persisted.matches(fragment).count(), 1); |
| 407 | } |
| 408 | for index in 1..=partial_count { |
| 409 | assert_eq!(persisted.matches(&format!("partial-{index}")).count(), 1); |
| 410 | } |
| 411 | let (tx, rx) = tokio::sync::oneshot::channel(); |
| 412 | handle |
| 413 | .send(Op::GetProviderRuntimeStatus { |
| 414 | tx: Arc::new(StdMutex::new(Some(tx))), |
| 415 | }) |
| 416 | .await |
| 417 | .unwrap(); |
| 418 | let readiness = tokio::time::timeout(CONTROL_TIMEOUT, rx) |
| 419 | .await |
| 420 | .expect("provider readiness stays responsive") |
| 421 | .unwrap(); |
| 422 | assert_eq!( |
| 423 | readiness.active_provider_requests, 0, |
| 424 | "no stream request permit may leak into idle state" |
| 425 | ); |
| 426 | handle.send(Op::Shutdown).await.unwrap(); |
| 427 | tokio::time::timeout(CONTROL_TIMEOUT, task) |
| 428 | .await |
| 429 | .expect("shutdown after loss must not block") |
| 430 | .unwrap(); |
| 431 | assert!( |
| 432 | !server.task.is_finished(), |
| 433 | "loopback server must not have panicked" |
| 434 | ); |
| 435 | } |
| 436 | |
| 437 | #[tokio::test(flavor = "multi_thread", worker_threads = 2)] |
| 438 | async fn terminal_partial_sse_loss_accepts_next_user_turn_on_same_engine() { |
| 439 | verify_next_user_turn_after_loss(Failure::TruncatedBody).await; |
| 440 | } |
| 441 | |
| 442 | #[tokio::test(flavor = "multi_thread", worker_threads = 2)] |
| 443 | async fn cancelled_partial_sse_releases_connection_and_accepts_next_user_turn() { |
| 444 | verify_next_user_turn_after_loss(Failure::StalledBody).await; |
| 445 | } |
| 446 |