From 9f53b2ab047d8d36d53eb8585a9bf96bd485cb50 Mon Sep 17 00:00:00 2001 From: Microck Date: Sat, 4 Jul 2026 16:08:11 +0000 Subject: [PATCH 1/3] 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 --- src/api.rs | 130 ++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 124 insertions(+), 6 deletions(-) diff --git a/src/api.rs b/src/api.rs index b02167e..38df614 100644 --- a/src/api.rs +++ b/src/api.rs @@ -7,7 +7,7 @@ use std::time::Duration; use futures_util::StreamExt; use reqwest::multipart; use reqwest::{Client, StatusCode, Url, header}; -use scraper::Html; +use scraper::{Html, Selector}; use serde::Deserialize; #[cfg(test)] use serde::Serialize; @@ -3780,12 +3780,15 @@ fn parse_assistant_thread_open_stream( let mut meta = AssistantMeta::default(); let mut folders = Vec::new(); let mut thread = None; + let mut thread_html = None; let mut messages = None; + let mut received_tags = Vec::new(); for frame in body.split("\0\n").filter(|frame| !frame.trim().is_empty()) { let Some((tag, payload)) = frame.split_once(':') else { continue; }; + received_tags.push(tag.to_string()); match tag { "hi" => { @@ -3807,6 +3810,9 @@ fn parse_assistant_thread_open_stream( })?; thread = Some(AssistantThread::from(payload)); } + "thread.html" => { + thread_html = Some(payload.to_string()); + } "messages.json" => { let payloads: Vec = serde_json::from_str(payload) .map_err(|error| { @@ -3840,14 +3846,19 @@ fn parse_assistant_thread_open_stream( } } + let thread = match (thread, thread_html) { + (Some(thread), _) => Ok(thread), + (None, Some(html)) => parse_assistant_thread_open_html(&html), + (None, None) => Err(KagiError::Parse(format!( + "assistant thread open response did not include a thread.json or thread.html frame (received tags: {})", + format_received_frame_tags(&received_tags) + ))), + }?; + Ok(AssistantThreadOpenResponse { meta, folders, - thread: thread.ok_or_else(|| { - KagiError::Parse( - "assistant thread open response did not include a thread.json frame".to_string(), - ) - })?, + thread, messages: messages.ok_or_else(|| { KagiError::Parse( "assistant thread open response did not include a messages.json frame".to_string(), @@ -3856,6 +3867,61 @@ fn parse_assistant_thread_open_stream( }) } +fn parse_assistant_thread_open_html(html: &str) -> Result { + let document = Html::parse_fragment(html); + let thread_selector = selector(".thread")?; + let title_selector = selector(".title")?; + + let element = document + .select(&thread_selector) + .next() + .ok_or_else(|| KagiError::Parse("assistant thread html missing thread item".to_string()))?; + let id = element + .value() + .attr("data-code") + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| KagiError::Parse("assistant thread html missing data-code".to_string()))? + .to_string(); + let title = element + .select(&title_selector) + .next() + .map(|node| node.text().collect::().trim().to_string()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| KagiError::Parse("assistant thread html missing title".to_string()))?; + + Ok(AssistantThread { + id, + title, + ack: String::new(), + created_at: String::new(), + expires_at: String::new(), + saved: element + .value() + .attr("data-saved") + .is_some_and(|value| value == "true"), + shared: element + .value() + .attr("data-public") + .is_some_and(|value| value == "true"), + branch_id: String::new(), + folder_ids: Vec::new(), + }) +} + +fn format_received_frame_tags(tags: &[String]) -> String { + if tags.is_empty() { + "".to_string() + } else { + tags.join(", ") + } +} + +fn selector(value: &str) -> Result { + Selector::parse(value) + .map_err(|error| KagiError::Parse(format!("failed to parse selector `{value}`: {error:?}"))) +} + fn parse_assistant_thread_list_stream( body: &str, ) -> Result { @@ -5786,6 +5852,58 @@ mod tests { assert_eq!(parsed.messages[0].trace_id.as_deref(), Some("trace-msg")); } + #[test] + fn parses_assistant_thread_open_stream_with_thread_html_fallback() { + let raw = concat!( + "hi:{\"v\":\"202603171911.stage.707e740\",\"trace\":\"trace-open-html\"}\0\n", + "tags.json:[]\0\n", + "thread.html:
  • \n", + " temp\n", + " \n", + " Testing Conversation\n", + " \n", + "
  • \0\n", + "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\":\"

    Hi

    \",\"md\":\"Hi\",\"metadata\":\"\",\"documents\":[],\"trace_id\":\"trace-msg\"}]\0\n" + ); + + let parsed = parse_assistant_thread_open_stream(raw).expect("thread open parses from html"); + + assert_eq!(parsed.meta.trace.as_deref(), Some("trace-open-html")); + assert_eq!(parsed.thread.id, "bb8254ea-4b02-4353-be53-b1bd8632e55e"); + assert_eq!(parsed.thread.title, "Testing Conversation"); + assert!(!parsed.thread.saved); + assert!(!parsed.thread.shared); + assert!(parsed.thread.ack.is_empty()); + assert!(parsed.thread.created_at.is_empty()); + assert!(parsed.thread.expires_at.is_empty()); + assert!(parsed.thread.branch_id.is_empty()); + assert!(parsed.thread.folder_ids.is_empty()); + assert_eq!(parsed.messages.len(), 1); + } + + #[test] + fn reports_received_tags_when_thread_open_stream_has_no_thread_frame() { + let raw = concat!( + "hi:{\"v\":\"202603171911.stage.707e740\",\"trace\":\"trace-open-missing\"}\0\n", + "tags.json:[]\0\n", + "messages.json:[{\"id\":\"msg-1\",\"thread_id\":\"thread-1\",\"created_at\":\"2026-03-16T06:19:07Z\",\"branch_list\":[],\"state\":\"done\",\"prompt\":\"Hello\",\"reply\":\"

    Hi

    \",\"md\":\"Hi\",\"metadata\":\"\",\"documents\":[],\"trace_id\":\"trace-msg\"}]\0\n" + ); + + let error = parse_assistant_thread_open_stream(raw) + .expect_err("missing thread frame should fail to parse"); + + assert_eq!( + error.to_string(), + "parse error: assistant thread open response did not include a thread.json or thread.html frame (received tags: hi, tags.json, messages.json)" + ); + } + #[test] fn parses_assistant_thread_list_stream() { let raw = concat!( From 966307003d5943261ad8fb9913c4f7b5f9fbaa15 Mon Sep 17 00:00:00 2001 From: Microck Date: Sat, 4 Jul 2026 16:10:24 +0000 Subject: [PATCH 2/3] chore(release): prepare v0.14.1 --- CHANGELOG.md | 6 ++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- npm/package.json | 2 +- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ae97fe..8e96ef9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ Before `1.0.0`, breaking changes may still ship in minor releases. ## [Unreleased] +## [0.14.1] + +### Fixed + +- 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. + ## [0.14.0] ### Added diff --git a/Cargo.lock b/Cargo.lock index 928dbee..ddca7ca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1055,7 +1055,7 @@ dependencies = [ [[package]] name = "kagi" -version = "0.14.0" +version = "0.14.1" dependencies = [ "clap", "clap_complete", diff --git a/Cargo.toml b/Cargo.toml index 123b055..8f47ad5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "kagi" -version = "0.14.0" +version = "0.14.1" edition = "2024" description = "Agent-native CLI for Kagi subscribers with JSON-first search output" license = "MIT" diff --git a/npm/package.json b/npm/package.json index 1f304ef..c5e128e 100644 --- a/npm/package.json +++ b/npm/package.json @@ -1,6 +1,6 @@ { "name": "kagi-cli", - "version": "0.14.0", + "version": "0.14.1", "description": "Node wrapper that installs the native kagi CLI binary", "license": "MIT", "repository": { From 37d7809e09ce0572aec7cc30a52bfdaf7f4d93b9 Mon Sep 17 00:00:00 2001 From: Microck Date: Sat, 4 Jul 2026 16:23:49 +0000 Subject: [PATCH 3/3] 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). --- src/api.rs | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/api.rs b/src/api.rs index 38df614..5b0f251 100644 --- a/src/api.rs +++ b/src/api.rs @@ -3869,8 +3869,8 @@ fn parse_assistant_thread_open_stream( fn parse_assistant_thread_open_html(html: &str) -> Result { let document = Html::parse_fragment(html); - let thread_selector = selector(".thread")?; - let title_selector = selector(".title")?; + let thread_selector = Selector::parse(".thread").expect("valid CSS selector"); + let title_selector = Selector::parse(".title").expect("valid CSS selector"); let element = document .select(&thread_selector) @@ -3890,6 +3890,11 @@ fn parse_assistant_thread_open_html(html: &str) -> Result>( + element.value().attr("data-folders").unwrap_or("[]"), + ) + .unwrap_or_default(); + Ok(AssistantThread { id, title, @@ -3905,7 +3910,7 @@ fn parse_assistant_thread_open_html(html: &str) -> Result String { } } -fn selector(value: &str) -> Result { - Selector::parse(value) - .map_err(|error| KagiError::Parse(format!("failed to parse selector `{value}`: {error:?}"))) -} - fn parse_assistant_thread_list_stream( body: &str, ) -> Result { @@ -5862,6 +5862,7 @@ mod tests { " data-saved=\"false\"\n", " data-public=\"false\"\n", " data-tags=\"[]\"\n", + " data-folders='[\"folder-1\", \"folder-2\"]'\n", " data-snippet=\"none\"\n", " >\n", " temp\n", @@ -5883,7 +5884,7 @@ mod tests { assert!(parsed.thread.created_at.is_empty()); assert!(parsed.thread.expires_at.is_empty()); assert!(parsed.thread.branch_id.is_empty()); - assert!(parsed.thread.folder_ids.is_empty()); + assert_eq!(parsed.thread.folder_ids, vec!["folder-1", "folder-2"]); assert_eq!(parsed.messages.len(), 1); }