Skip to content

Commit 9f53b2a

Browse files
committed
fix(assistant): add thread.html fallback for thread_open parser
Kagi changed the Assistant backend and some accounts no longer receive the thread.json frame in thread_open responses for new threads. This adds a fallback that parses thread metadata from the thread.html frame (which Kagi still sends), and improves the error message to list received frame tags when neither is present. Fixes #123
1 parent e11104b commit 9f53b2a

1 file changed

Lines changed: 124 additions & 6 deletions

File tree

src/api.rs

Lines changed: 124 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use std::time::Duration;
77
use futures_util::StreamExt;
88
use reqwest::multipart;
99
use reqwest::{Client, StatusCode, Url, header};
10-
use scraper::Html;
10+
use scraper::{Html, Selector};
1111
use serde::Deserialize;
1212
#[cfg(test)]
1313
use 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(".thread")?;
3873+
let title_selector = selector(".title")?;
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+
Ok(AssistantThread {
3894+
id,
3895+
title,
3896+
ack: String::new(),
3897+
created_at: String::new(),
3898+
expires_at: String::new(),
3899+
saved: element
3900+
.value()
3901+
.attr("data-saved")
3902+
.is_some_and(|value| value == "true"),
3903+
shared: element
3904+
.value()
3905+
.attr("data-public")
3906+
.is_some_and(|value| value == "true"),
3907+
branch_id: String::new(),
3908+
folder_ids: Vec::new(),
3909+
})
3910+
}
3911+
3912+
fn format_received_frame_tags(tags: &[String]) -> String {
3913+
if tags.is_empty() {
3914+
"<none>".to_string()
3915+
} else {
3916+
tags.join(", ")
3917+
}
3918+
}
3919+
3920+
fn selector(value: &str) -> Result<Selector, KagiError> {
3921+
Selector::parse(value)
3922+
.map_err(|error| KagiError::Parse(format!("failed to parse selector `{value}`: {error:?}")))
3923+
}
3924+
38593925
fn parse_assistant_thread_list_stream(
38603926
body: &str,
38613927
) -> Result<AssistantThreadListResponse, KagiError> {
@@ -5786,6 +5852,58 @@ 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-snippet=\"none\"\n",
5866+
" >\n",
5867+
" <i title=\"Temporary\">temp</i>\n",
5868+
" <a href=\"/assistant/thread/bb8254ea-4b02-4353-be53-b1bd8632e55e\">\n",
5869+
" <span class=\"title\">Testing Conversation</span>\n",
5870+
" </a>\n",
5871+
"</li>\0\n",
5872+
"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"
5873+
);
5874+
5875+
let parsed = parse_assistant_thread_open_stream(raw).expect("thread open parses from html");
5876+
5877+
assert_eq!(parsed.meta.trace.as_deref(), Some("trace-open-html"));
5878+
assert_eq!(parsed.thread.id, "bb8254ea-4b02-4353-be53-b1bd8632e55e");
5879+
assert_eq!(parsed.thread.title, "Testing Conversation");
5880+
assert!(!parsed.thread.saved);
5881+
assert!(!parsed.thread.shared);
5882+
assert!(parsed.thread.ack.is_empty());
5883+
assert!(parsed.thread.created_at.is_empty());
5884+
assert!(parsed.thread.expires_at.is_empty());
5885+
assert!(parsed.thread.branch_id.is_empty());
5886+
assert!(parsed.thread.folder_ids.is_empty());
5887+
assert_eq!(parsed.messages.len(), 1);
5888+
}
5889+
5890+
#[test]
5891+
fn reports_received_tags_when_thread_open_stream_has_no_thread_frame() {
5892+
let raw = concat!(
5893+
"hi:{\"v\":\"202603171911.stage.707e740\",\"trace\":\"trace-open-missing\"}\0\n",
5894+
"tags.json:[]\0\n",
5895+
"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"
5896+
);
5897+
5898+
let error = parse_assistant_thread_open_stream(raw)
5899+
.expect_err("missing thread frame should fail to parse");
5900+
5901+
assert_eq!(
5902+
error.to_string(),
5903+
"parse error: assistant thread open response did not include a thread.json or thread.html frame (received tags: hi, tags.json, messages.json)"
5904+
);
5905+
}
5906+
57895907
#[test]
57905908
fn parses_assistant_thread_list_stream() {
57915909
let raw = concat!(

0 commit comments

Comments
 (0)