@@ -7,7 +7,7 @@ use std::time::Duration;
77use futures_util:: StreamExt ;
88use reqwest:: multipart;
99use reqwest:: { Client , StatusCode , Url , header} ;
10- use scraper:: Html ;
10+ use scraper:: { Html , Selector } ;
1111use serde:: Deserialize ;
1212#[ cfg( test) ]
1313use serde:: Serialize ;
@@ -3780,12 +3780,15 @@ fn parse_assistant_thread_open_stream(
37803780 let mut meta = AssistantMeta :: default ( ) ;
37813781 let mut folders = Vec :: new ( ) ;
37823782 let mut thread = None ;
3783+ let mut thread_html = None ;
37833784 let mut messages = None ;
3785+ let mut received_tags = Vec :: new ( ) ;
37843786
37853787 for frame in body. split ( "\0 \n " ) . filter ( |frame| !frame. trim ( ) . is_empty ( ) ) {
37863788 let Some ( ( tag, payload) ) = frame. split_once ( ':' ) else {
37873789 continue ;
37883790 } ;
3791+ received_tags. push ( tag. to_string ( ) ) ;
37893792
37903793 match tag {
37913794 "hi" => {
@@ -3807,6 +3810,9 @@ fn parse_assistant_thread_open_stream(
38073810 } ) ?;
38083811 thread = Some ( AssistantThread :: from ( payload) ) ;
38093812 }
3813+ "thread.html" => {
3814+ thread_html = Some ( payload. to_string ( ) ) ;
3815+ }
38103816 "messages.json" => {
38113817 let payloads: Vec < AssistantMessagePayload > = serde_json:: from_str ( payload)
38123818 . map_err ( |error| {
@@ -3840,14 +3846,19 @@ fn parse_assistant_thread_open_stream(
38403846 }
38413847 }
38423848
3849+ let thread = match ( thread, thread_html) {
3850+ ( Some ( thread) , _) => Ok ( thread) ,
3851+ ( None , Some ( html) ) => parse_assistant_thread_open_html ( & html) ,
3852+ ( None , None ) => Err ( KagiError :: Parse ( format ! (
3853+ "assistant thread open response did not include a thread.json or thread.html frame (received tags: {})" ,
3854+ format_received_frame_tags( & received_tags)
3855+ ) ) ) ,
3856+ } ?;
3857+
38433858 Ok ( AssistantThreadOpenResponse {
38443859 meta,
38453860 folders,
3846- thread : thread. ok_or_else ( || {
3847- KagiError :: Parse (
3848- "assistant thread open response did not include a thread.json frame" . to_string ( ) ,
3849- )
3850- } ) ?,
3861+ thread,
38513862 messages : messages. ok_or_else ( || {
38523863 KagiError :: Parse (
38533864 "assistant thread open response did not include a messages.json frame" . to_string ( ) ,
@@ -3856,6 +3867,61 @@ fn parse_assistant_thread_open_stream(
38563867 } )
38573868}
38583869
3870+ fn parse_assistant_thread_open_html ( html : & str ) -> Result < AssistantThread , KagiError > {
3871+ let document = Html :: parse_fragment ( html) ;
3872+ let thread_selector = Selector :: parse ( ".thread" ) . expect ( "valid CSS selector" ) ;
3873+ let title_selector = Selector :: parse ( ".title" ) . expect ( "valid CSS selector" ) ;
3874+
3875+ let element = document
3876+ . select ( & thread_selector)
3877+ . next ( )
3878+ . ok_or_else ( || KagiError :: Parse ( "assistant thread html missing thread item" . to_string ( ) ) ) ?;
3879+ let id = element
3880+ . value ( )
3881+ . attr ( "data-code" )
3882+ . map ( str:: trim)
3883+ . filter ( |value| !value. is_empty ( ) )
3884+ . ok_or_else ( || KagiError :: Parse ( "assistant thread html missing data-code" . to_string ( ) ) ) ?
3885+ . to_string ( ) ;
3886+ let title = element
3887+ . select ( & title_selector)
3888+ . next ( )
3889+ . map ( |node| node. text ( ) . collect :: < String > ( ) . trim ( ) . to_string ( ) )
3890+ . filter ( |value| !value. is_empty ( ) )
3891+ . ok_or_else ( || KagiError :: Parse ( "assistant thread html missing title" . to_string ( ) ) ) ?;
3892+
3893+ let folder_ids = serde_json:: from_str :: < Vec < String > > (
3894+ element. value ( ) . attr ( "data-folders" ) . unwrap_or ( "[]" ) ,
3895+ )
3896+ . unwrap_or_default ( ) ;
3897+
3898+ Ok ( AssistantThread {
3899+ id,
3900+ title,
3901+ ack : String :: new ( ) ,
3902+ created_at : String :: new ( ) ,
3903+ expires_at : String :: new ( ) ,
3904+ saved : element
3905+ . value ( )
3906+ . attr ( "data-saved" )
3907+ . is_some_and ( |value| value == "true" ) ,
3908+ shared : element
3909+ . value ( )
3910+ . attr ( "data-public" )
3911+ . is_some_and ( |value| value == "true" ) ,
3912+ branch_id : String :: new ( ) ,
3913+ folder_ids,
3914+ } )
3915+ }
3916+
3917+ fn format_received_frame_tags ( tags : & [ String ] ) -> String {
3918+ if tags. is_empty ( ) {
3919+ "<none>" . to_string ( )
3920+ } else {
3921+ tags. join ( ", " )
3922+ }
3923+ }
3924+
38593925fn parse_assistant_thread_list_stream (
38603926 body : & str ,
38613927) -> Result < AssistantThreadListResponse , KagiError > {
@@ -5786,6 +5852,59 @@ mod tests {
57865852 assert_eq ! ( parsed. messages[ 0 ] . trace_id. as_deref( ) , Some ( "trace-msg" ) ) ;
57875853 }
57885854
5855+ #[ test]
5856+ fn parses_assistant_thread_open_stream_with_thread_html_fallback ( ) {
5857+ let raw = concat ! (
5858+ "hi:{\" v\" :\" 202603171911.stage.707e740\" ,\" trace\" :\" trace-open-html\" }\0 \n " ,
5859+ "tags.json:[]\0 \n " ,
5860+ "thread.html:<li class=\" thread\" \n " ,
5861+ " data-code=\" bb8254ea-4b02-4353-be53-b1bd8632e55e\" \n " ,
5862+ " data-saved=\" false\" \n " ,
5863+ " data-public=\" false\" \n " ,
5864+ " data-tags=\" []\" \n " ,
5865+ " data-folders='[\" folder-1\" , \" folder-2\" ]'\n " ,
5866+ " data-snippet=\" none\" \n " ,
5867+ " >\n " ,
5868+ " <i title=\" Temporary\" >temp</i>\n " ,
5869+ " <a href=\" /assistant/thread/bb8254ea-4b02-4353-be53-b1bd8632e55e\" >\n " ,
5870+ " <span class=\" title\" >Testing Conversation</span>\n " ,
5871+ " </a>\n " ,
5872+ "</li>\0 \n " ,
5873+ "messages.json:[{\" id\" :\" msg-1\" ,\" thread_id\" :\" bb8254ea-4b02-4353-be53-b1bd8632e55e\" ,\" created_at\" :\" 2026-03-16T06:19:07Z\" ,\" branch_list\" :[],\" state\" :\" done\" ,\" prompt\" :\" Hello\" ,\" reply\" :\" <p>Hi</p>\" ,\" md\" :\" Hi\" ,\" metadata\" :\" \" ,\" documents\" :[],\" trace_id\" :\" trace-msg\" }]\0 \n "
5874+ ) ;
5875+
5876+ let parsed = parse_assistant_thread_open_stream ( raw) . expect ( "thread open parses from html" ) ;
5877+
5878+ assert_eq ! ( parsed. meta. trace. as_deref( ) , Some ( "trace-open-html" ) ) ;
5879+ assert_eq ! ( parsed. thread. id, "bb8254ea-4b02-4353-be53-b1bd8632e55e" ) ;
5880+ assert_eq ! ( parsed. thread. title, "Testing Conversation" ) ;
5881+ assert ! ( !parsed. thread. saved) ;
5882+ assert ! ( !parsed. thread. shared) ;
5883+ assert ! ( parsed. thread. ack. is_empty( ) ) ;
5884+ assert ! ( parsed. thread. created_at. is_empty( ) ) ;
5885+ assert ! ( parsed. thread. expires_at. is_empty( ) ) ;
5886+ assert ! ( parsed. thread. branch_id. is_empty( ) ) ;
5887+ assert_eq ! ( parsed. thread. folder_ids, vec![ "folder-1" , "folder-2" ] ) ;
5888+ assert_eq ! ( parsed. messages. len( ) , 1 ) ;
5889+ }
5890+
5891+ #[ test]
5892+ fn reports_received_tags_when_thread_open_stream_has_no_thread_frame ( ) {
5893+ let raw = concat ! (
5894+ "hi:{\" v\" :\" 202603171911.stage.707e740\" ,\" trace\" :\" trace-open-missing\" }\0 \n " ,
5895+ "tags.json:[]\0 \n " ,
5896+ "messages.json:[{\" id\" :\" msg-1\" ,\" thread_id\" :\" thread-1\" ,\" created_at\" :\" 2026-03-16T06:19:07Z\" ,\" branch_list\" :[],\" state\" :\" done\" ,\" prompt\" :\" Hello\" ,\" reply\" :\" <p>Hi</p>\" ,\" md\" :\" Hi\" ,\" metadata\" :\" \" ,\" documents\" :[],\" trace_id\" :\" trace-msg\" }]\0 \n "
5897+ ) ;
5898+
5899+ let error = parse_assistant_thread_open_stream ( raw)
5900+ . expect_err ( "missing thread frame should fail to parse" ) ;
5901+
5902+ assert_eq ! (
5903+ error. to_string( ) ,
5904+ "parse error: assistant thread open response did not include a thread.json or thread.html frame (received tags: hi, tags.json, messages.json)"
5905+ ) ;
5906+ }
5907+
57895908 #[ test]
57905909 fn parses_assistant_thread_list_stream ( ) {
57915910 let raw = concat ! (
0 commit comments