@@ -111,6 +111,10 @@ pub async fn polish(
111111 return raw. to_string ( ) ;
112112 }
113113 match polish_inner ( cfg, raw, app, ctx) . await {
114+ // An empty completion (no choices, or blank content) would silently
115+ // drop the utterance. Keep the raw transcript instead; don't record
116+ // the empty result in the rolling context.
117+ Ok ( out) if out. is_empty ( ) => raw. to_string ( ) ,
114118 Ok ( out) => {
115119 ctx. append ( & out) ;
116120 out
@@ -191,7 +195,14 @@ async fn polish_inner(
191195 } ;
192196
193197 let url = format ! ( "{}/chat/completions" , cfg. base_url. trim_end_matches( '/' ) ) ;
194- let mut req = reqwest:: Client :: new ( ) . post ( & url) . json ( & body) ;
198+ // Bound the round-trip so a hung endpoint can't wedge the collector on a
199+ // single segment. On timeout reqwest errors, falling through to the raw
200+ // transcript like any other polish failure.
201+ let client = reqwest:: Client :: builder ( )
202+ . timeout ( std:: time:: Duration :: from_secs ( 30 ) )
203+ . build ( )
204+ . context ( "build polish http client" ) ?;
205+ let mut req = client. post ( & url) . json ( & body) ;
195206
196207 // Bearer auth only if the configured env var is set. Lets local servers
197208 // (Ollama, llama.cpp) work with no key configured.
@@ -223,6 +234,122 @@ async fn polish_inner(
223234#[ cfg( test) ]
224235mod tests {
225236 use super :: * ;
237+ use tokio:: io:: { AsyncReadExt , AsyncWriteExt } ;
238+ use tokio:: net:: TcpListener ;
239+
240+ /// Spin up a one-shot HTTP server that reads a full request then replies
241+ /// with `response`. Returns a `base_url` pointing at it. Draining the
242+ /// request body before replying avoids an RST truncating reqwest's read.
243+ async fn stub_openai ( response : String ) -> String {
244+ let listener = TcpListener :: bind ( "127.0.0.1:0" ) . await . unwrap ( ) ;
245+ let addr = listener. local_addr ( ) . unwrap ( ) ;
246+ tokio:: spawn ( async move {
247+ let Ok ( ( mut stream, _) ) = listener. accept ( ) . await else {
248+ return ;
249+ } ;
250+ let mut buf = Vec :: new ( ) ;
251+ let mut tmp = [ 0u8 ; 1024 ] ;
252+ let header_end = loop {
253+ let n = match stream. read ( & mut tmp) . await {
254+ Ok ( 0 ) | Err ( _) => return ,
255+ Ok ( n) => n,
256+ } ;
257+ buf. extend_from_slice ( & tmp[ ..n] ) ;
258+ if let Some ( pos) = buf. windows ( 4 ) . position ( |w| w == b"\r \n \r \n " ) {
259+ break pos + 4 ;
260+ }
261+ } ;
262+ let headers = String :: from_utf8_lossy ( & buf[ ..header_end] ) . to_ascii_lowercase ( ) ;
263+ let content_len = headers
264+ . lines ( )
265+ . find_map ( |l| l. strip_prefix ( "content-length:" ) )
266+ . and_then ( |v| v. trim ( ) . parse :: < usize > ( ) . ok ( ) )
267+ . unwrap_or ( 0 ) ;
268+ while buf. len ( ) < header_end + content_len {
269+ match stream. read ( & mut tmp) . await {
270+ Ok ( 0 ) | Err ( _) => break ,
271+ Ok ( n) => buf. extend_from_slice ( & tmp[ ..n] ) ,
272+ }
273+ }
274+ let _ = stream. write_all ( response. as_bytes ( ) ) . await ;
275+ let _ = stream. flush ( ) . await ;
276+ } ) ;
277+ format ! ( "http://{addr}/v1" )
278+ }
279+
280+ fn http_response ( status : & str , body : & str ) -> String {
281+ format ! (
282+ "HTTP/1.1 {status}\r \n Content-Type: application/json\r \n Content-Length: {}\r \n Connection: close\r \n \r \n {body}" ,
283+ body. len( )
284+ )
285+ }
286+
287+ fn enabled_cfg ( base_url : String ) -> PolishConfig {
288+ PolishConfig {
289+ enabled : true ,
290+ base_url,
291+ model : "test-model" . into ( ) ,
292+ api_key_env : String :: new ( ) ,
293+ per_app_tone : true ,
294+ }
295+ }
296+
297+ #[ tokio:: test]
298+ async fn success_returns_model_output_and_records_context ( ) {
299+ let body = r#"{"choices":[{"message":{"content":" Cleaned up text. "}}]}"# ;
300+ let cfg = enabled_cfg ( stub_openai ( http_response ( "200 OK" , body) ) . await ) ;
301+ let ctx = PolishContext :: new ( ) ;
302+ let out = polish ( & cfg, "um cleaned up text" , None , & ctx) . await ;
303+ assert_eq ! ( out, "Cleaned up text." ) ;
304+ assert_eq ! ( ctx. snapshot( ) , "Cleaned up text." ) ;
305+ assert ! ( !ctx. is_disabled( ) ) ;
306+ }
307+
308+ #[ tokio:: test]
309+ async fn http_error_falls_back_to_raw_and_disables_session ( ) {
310+ let cfg = enabled_cfg ( stub_openai ( http_response ( "401 Unauthorized" , "{}" ) ) . await ) ;
311+ let ctx = PolishContext :: new ( ) ;
312+ let out = polish ( & cfg, "raw words" , None , & ctx) . await ;
313+ assert_eq ! (
314+ out, "raw words" ,
315+ "an API failure must not drop the transcript"
316+ ) ;
317+ assert ! (
318+ ctx. is_disabled( ) ,
319+ "one failure disables polish for the session"
320+ ) ;
321+ assert ! (
322+ ctx. snapshot( ) . is_empty( ) ,
323+ "failed polish must not pollute context"
324+ ) ;
325+ }
326+
327+ #[ tokio:: test]
328+ async fn empty_completion_falls_back_to_raw ( ) {
329+ // Both an empty choices list and blank content must keep the utterance.
330+ for body in [
331+ r#"{"choices":[]}"# ,
332+ r#"{"choices":[{"message":{"content":" "}}]}"# ,
333+ ] {
334+ let cfg = enabled_cfg ( stub_openai ( http_response ( "200 OK" , body) ) . await ) ;
335+ let ctx = PolishContext :: new ( ) ;
336+ let out = polish ( & cfg, "keep this" , None , & ctx) . await ;
337+ assert_eq ! ( out, "keep this" , "empty completion must not drop text" ) ;
338+ assert ! (
339+ ctx. snapshot( ) . is_empty( ) ,
340+ "empty output must not enter context"
341+ ) ;
342+ }
343+ }
344+
345+ #[ tokio:: test]
346+ async fn malformed_body_falls_back_to_raw ( ) {
347+ let cfg = enabled_cfg ( stub_openai ( http_response ( "200 OK" , "this is not json" ) ) . await ) ;
348+ let ctx = PolishContext :: new ( ) ;
349+ let out = polish ( & cfg, "keep me" , None , & ctx) . await ;
350+ assert_eq ! ( out, "keep me" ) ;
351+ assert ! ( ctx. is_disabled( ) ) ;
352+ }
226353
227354 #[ test]
228355 fn context_starts_empty ( ) {
0 commit comments