@@ -10,6 +10,8 @@ use crate::error::{CliError, CliResult};
1010use futures_util:: StreamExt ;
1111use reqwest:: Response ;
1212use serde:: Deserialize ;
13+ use std:: io:: Write ;
14+ use std:: path:: Path ;
1315
1416/// Sender types matching ChatMessage.
1517#[ derive( Debug , Clone , PartialEq , Deserialize ) ]
@@ -167,15 +169,52 @@ pub async fn process_stream<F>(response: Response, handler: F) -> CliResult<Opti
167169where
168170 F : FnMut ( ChatMessage ) ,
169171{
170- process_stream_with_debug ( response, handler, false ) . await
172+ process_stream_inner ( response, handler, None ) . await
171173}
172174
173- /// Process a streaming response with optional debug output .
175+ /// Process a streaming response with debug events optionally written to stderr .
174176pub async fn process_stream_with_debug < F > (
175177 response : Response ,
176- mut handler : F ,
178+ handler : F ,
177179 debug : bool ,
178180) -> CliResult < Option < String > >
181+ where
182+ F : FnMut ( ChatMessage ) ,
183+ {
184+ let writer: Option < Box < dyn Write + Send > > = if debug {
185+ Some ( Box :: new ( std:: io:: stderr ( ) ) )
186+ } else {
187+ None
188+ } ;
189+ process_stream_inner ( response, handler, writer) . await
190+ }
191+
192+ /// Process a streaming response and append debug events to a file.
193+ ///
194+ /// The file is opened in append mode so multiple streams in the same session
195+ /// accumulate into one log. Callers that want a fresh log per session should
196+ /// truncate the file once before invoking this.
197+ pub async fn process_stream_with_debug_file < F > (
198+ response : Response ,
199+ handler : F ,
200+ debug_log_path : & Path ,
201+ ) -> CliResult < Option < String > >
202+ where
203+ F : FnMut ( ChatMessage ) ,
204+ {
205+ let file = std:: fs:: OpenOptions :: new ( )
206+ . create ( true )
207+ . append ( true )
208+ . open ( debug_log_path) ?;
209+ process_stream_inner ( response, handler, Some ( Box :: new ( file) ) ) . await
210+ }
211+
212+ /// Shared implementation for stream processing.
213+ async fn process_stream_inner < F > (
214+ response : Response ,
215+ mut handler : F ,
216+ mut debug_writer : Option < Box < dyn Write + Send > > ,
217+ ) -> CliResult < Option < String > >
179218where
180219 F : FnMut ( ChatMessage ) ,
181220{
@@ -186,7 +225,28 @@ where
186225 let mut message_count = 0 ;
187226
188227 while let Some ( chunk) = stream. next ( ) . await {
189- let chunk = chunk. map_err ( |e| CliError :: NetworkError ( e. to_string ( ) ) ) ?;
228+ let chunk = match chunk {
229+ Ok ( c) => c,
230+ Err ( e) => {
231+ // Surface where the stream died — the most useful detail when
232+ // debugging "error decoding response body" failures.
233+ if let Some ( w) = debug_writer. as_mut ( ) {
234+ let _ = writeln ! (
235+ w,
236+ "[DEBUG] Stream error after {event_count} events, {message_count} messages: {e}"
237+ ) ;
238+ if !buffer. is_empty ( ) {
239+ let _ = writeln ! (
240+ w,
241+ "[DEBUG] Unparsed buffer at error ({} bytes): {buffer:?}" ,
242+ buffer. len( )
243+ ) ;
244+ }
245+ let _ = w. flush ( ) ;
246+ }
247+ return Err ( CliError :: NetworkError ( e. to_string ( ) ) ) ;
248+ }
249+ } ;
190250 let text = String :: from_utf8_lossy ( & chunk) ;
191251 buffer. push_str ( & text) ;
192252
@@ -197,8 +257,8 @@ where
197257
198258 if let Some ( ( event_type, payload) ) = parse_event ( & event_str) {
199259 event_count += 1 ;
200- if debug {
201- eprintln ! ( "[DEBUG] Event #{event_count}: {event_type:?}" ) ;
260+ if let Some ( w ) = debug_writer . as_mut ( ) {
261+ let _ = writeln ! ( w , "[DEBUG] Event #{event_count}: {event_type:?}" ) ;
202262 }
203263
204264 // Capture conversation ID from any event
@@ -211,8 +271,9 @@ where
211271 if let Some ( data) = payload. data {
212272 if let Some ( message) = data. message {
213273 message_count += 1 ;
214- if debug {
215- eprintln ! (
274+ if let Some ( w) = debug_writer. as_mut ( ) {
275+ let _ = writeln ! (
276+ w,
216277 "[DEBUG] Message #{}: sender={:?}, content_blocks={}" ,
217278 message_count,
218279 message. sender,
@@ -231,8 +292,8 @@ where
231292 if !buffer. trim ( ) . is_empty ( ) {
232293 if let Some ( ( event_type, payload) ) = parse_event ( & buffer) {
233294 event_count += 1 ;
234- if debug {
235- eprintln ! ( "[DEBUG] Final Event #{event_count}: {event_type:?}" ) ;
295+ if let Some ( w ) = debug_writer . as_mut ( ) {
296+ let _ = writeln ! ( w , "[DEBUG] Final Event #{event_count}: {event_type:?}" ) ;
236297 }
237298
238299 if let Some ( id) = & payload. conversation_id {
@@ -242,8 +303,9 @@ where
242303 if let Some ( data) = payload. data {
243304 if let Some ( message) = data. message {
244305 message_count += 1 ;
245- if debug {
246- eprintln ! (
306+ if let Some ( w) = debug_writer. as_mut ( ) {
307+ let _ = writeln ! (
308+ w,
247309 "[DEBUG] Final Message #{}: sender={:?}" ,
248310 message_count, message. sender
249311 ) ;
@@ -255,8 +317,12 @@ where
255317 }
256318 }
257319
258- if debug {
259- eprintln ! ( "[DEBUG] Stream complete: {event_count} events, {message_count} messages" ) ;
320+ if let Some ( w) = debug_writer. as_mut ( ) {
321+ let _ = writeln ! (
322+ w,
323+ "[DEBUG] Stream complete: {event_count} events, {message_count} messages"
324+ ) ;
325+ let _ = w. flush ( ) ;
260326 }
261327
262328 Ok ( conversation_id)
@@ -352,4 +418,44 @@ data: {"conversationId":"conv-1","data":{"message":{"sender":"assistant","conten
352418 let tools = message. tools_used ( ) ;
353419 assert_eq ! ( tools, vec![ "searchMetadata" ] ) ;
354420 }
421+
422+ #[ tokio:: test]
423+ async fn test_process_stream_with_debug_file_writes_events ( ) {
424+ use wiremock:: matchers:: { method, path} ;
425+ use wiremock:: { Mock , MockServer , ResponseTemplate } ;
426+
427+ let server = MockServer :: start ( ) . await ;
428+ let body = "event: stream-start\n data: {\" streamId\" :\" s1\" ,\" conversationId\" :\" c1\" ,\" sequence\" :0}\n \n event: message\n data: {\" conversationId\" :\" c1\" ,\" data\" :{\" message\" :{\" sender\" :\" assistant\" ,\" content\" :[{\" textMessage\" :\" hi\" }],\" conversationId\" :\" c1\" }}}\n \n event: stream-completed\n data: {\" message\" :\" done\" ,\" type\" :\" completed\" }\n \n " ;
429+
430+ Mock :: given ( method ( "GET" ) )
431+ . and ( path ( "/sse" ) )
432+ . respond_with (
433+ ResponseTemplate :: new ( 200 )
434+ . insert_header ( "content-type" , "text/event-stream" )
435+ . set_body_string ( body) ,
436+ )
437+ . mount ( & server)
438+ . await ;
439+
440+ let response = reqwest:: get ( format ! ( "{}/sse" , server. uri( ) ) ) . await . unwrap ( ) ;
441+
442+ let log_path =
443+ std:: env:: temp_dir ( ) . join ( format ! ( "ai-sdk-test-debug-{}.log" , uuid:: Uuid :: new_v4( ) ) ) ;
444+ // Ensure clean slate
445+ let _ = std:: fs:: remove_file ( & log_path) ;
446+
447+ let conv_id = process_stream_with_debug_file ( response, |_msg| { } , & log_path)
448+ . await
449+ . unwrap ( ) ;
450+ assert_eq ! ( conv_id. as_deref( ) , Some ( "c1" ) ) ;
451+
452+ let contents = std:: fs:: read_to_string ( & log_path) . unwrap ( ) ;
453+ assert ! ( contents. contains( "Event #1: StreamStart" ) ) ;
454+ assert ! ( contents. contains( "Event #2: Message" ) ) ;
455+ assert ! ( contents. contains( "Message #1: sender=Assistant" ) ) ;
456+ assert ! ( contents. contains( "Event #3: StreamCompleted" ) ) ;
457+ assert ! ( contents. contains( "Stream complete: 3 events, 1 messages" ) ) ;
458+
459+ let _ = std:: fs:: remove_file ( & log_path) ;
460+ }
355461}
0 commit comments