Skip to content

Commit 2cb7500

Browse files
authored
fix(assistant): add thread.html fallback for thread_open parser (#131)
* 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 * chore(release): prepare v0.14.1 * fix(assistant): parse folder_ids from data-folders in HTML fallback Address review feedback from Codex (P2: preserve folder state) and Greptile (P2: use expect for compile-time constant selectors).
1 parent e11104b commit 2cb7500

5 files changed

Lines changed: 134 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ Before `1.0.0`, breaking changes may still ship in minor releases.
77

88
## [Unreleased]
99

10+
## [0.14.1]
11+
12+
### Fixed
13+
14+
- Assistant `thread_open` no longer fails on new threads after Kagi's backend URL change. When the `thread.json` stream frame is absent, the parser now falls back to the `thread.html` frame to extract thread metadata. The error message for missing thread frames now lists which stream tags were received to aid future debugging.
15+
1016
## [0.14.0]
1117

1218
### Added

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "kagi"
3-
version = "0.14.0"
3+
version = "0.14.1"
44
edition = "2024"
55
description = "Agent-native CLI for Kagi subscribers with JSON-first search output"
66
license = "MIT"

npm/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "kagi-cli",
3-
"version": "0.14.0",
3+
"version": "0.14.1",
44
"description": "Node wrapper that installs the native kagi CLI binary",
55
"license": "MIT",
66
"repository": {

src/api.rs

Lines changed: 125 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::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+
38593925
fn 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

Comments
 (0)