返回 CodeWhale
sse_turn_recovery.rs
根目录 / crates / tui / src / core / engine / tests / sse_turn_recovery.rs
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
446 lines RUST