Skip to content

Commit 0040835

Browse files
committed
fix(assistant): avoid stream response cutoff
1 parent 3a64e47 commit 0040835

5 files changed

Lines changed: 202 additions & 4 deletions

File tree

CHANGELOG.md

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

1212
- `kagi search` and `kagi batch` accept `--limit <N>` to cap the number of results returned (truncated locally; Kagi's search endpoints have no native count parameter)
1313

14+
### Fixed
15+
16+
- `kagi assistant` no longer cuts off long streamed prompt responses at the generic 30 second API timeout
17+
1418
## [0.5.0]
1519

1620
### Added

Cargo.lock

Lines changed: 112 additions & 0 deletions
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
@@ -28,7 +28,7 @@ clap_complete = "4.6.2"
2828
cliclack = "0.5.4"
2929
console = "0.16.3"
3030
ctrlc = "3.5.2"
31-
reqwest = { version = "0.12.15", default-features = false, features = ["json", "multipart", "rustls-tls"] }
31+
reqwest = { version = "0.12.15", default-features = false, features = ["brotli", "deflate", "gzip", "json", "multipart", "rustls-tls"] }
3232
scraper = "0.26.0"
3333
serde = { version = "1.0.228", features = ["derive"] }
3434
serde_json = "1.0.149"

src/api.rs

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3173,7 +3173,7 @@ async fn execute_assistant_stream(
31733173
));
31743174
}
31753175

3176-
let client = build_client()?;
3176+
let client = http::client_assistant_stream()?;
31773177
let response = client
31783178
.post(url)
31793179
.header(header::COOKIE, format!("kagi_session={token}"))
@@ -3200,7 +3200,7 @@ async fn execute_assistant_multipart_stream(
32003200
));
32013201
}
32023202

3203-
let client = build_client()?;
3203+
let client = http::client_assistant_stream()?;
32043204
let state_json = serde_json::to_vec(state).map_err(|error| {
32053205
KagiError::Config(format!(
32063206
"failed to serialize Assistant prompt upload state: {error}"
@@ -4479,7 +4479,7 @@ mod tests {
44794479
Arc,
44804480
atomic::{AtomicBool, Ordering},
44814481
};
4482-
use std::time::{SystemTime, UNIX_EPOCH};
4482+
use std::time::{Duration, SystemTime, UNIX_EPOCH};
44834483
use tempfile::TempDir;
44844484

44854485
struct ScopedEnvVar {
@@ -4920,6 +4920,19 @@ mod tests {
49204920
assert_eq!(parsed.message.trace_id.as_deref(), Some("trace-message-1"));
49214921
}
49224922

4923+
#[test]
4924+
fn parses_assistant_prompt_stream_without_expires_at() {
4925+
let raw = concat!(
4926+
"hi:{\"v\":\"202603091651.stage.c128588\",\"trace\":\"trace-123\"}\0\n",
4927+
"thread.json:{\"id\":\"thread-1\",\"title\":\"Greeting\",\"ack\":\"2026-03-16T06:19:07Z\",\"created_at\":\"2026-03-16T06:19:07Z\",\"saved\":false,\"shared\":false,\"branch_id\":\"00000000-0000-4000-0000-000000000000\",\"tag_ids\":[]}\0\n",
4928+
"new_message.json:{\"id\":\"msg-1\",\"thread_id\":\"thread-1\",\"created_at\":\"2026-03-16T06:19:07Z\",\"branch_list\":[\"00000000-0000-4000-0000-000000000000\"],\"state\":\"done\",\"prompt\":\"Hello\",\"reply\":\"<p>Hi</p>\",\"md\":\"Hi\",\"references_html\":\"<ol><li>Doc</li></ol>\",\"references_md\":\"1. [Doc](https://example.com)\",\"metadata\":\"<li>meta</li>\",\"documents\":[],\"trace_id\":\"trace-message-1\"}\0\n"
4929+
);
4930+
4931+
let parsed = parse_assistant_prompt_stream(raw).expect("assistant stream parses");
4932+
assert_eq!(parsed.thread.id, "thread-1");
4933+
assert!(parsed.thread.expires_at.is_empty());
4934+
}
4935+
49234936
#[test]
49244937
fn parses_assistant_thread_cursor_from_cursor_payload() {
49254938
assert_eq!(
@@ -5172,6 +5185,50 @@ mod tests {
51725185
assert_eq!(response.message.markdown.as_deref(), Some("attached-note"));
51735186
}
51745187

5188+
#[tokio::test]
5189+
#[allow(clippy::await_holding_lock)]
5190+
async fn assistant_prompt_accepts_delayed_stream_response() {
5191+
use httpmock::Method::POST;
5192+
use httpmock::MockServer;
5193+
5194+
let server = MockServer::start();
5195+
let _prompt = server.mock(|when, then| {
5196+
when.method(POST)
5197+
.path("/assistant/prompt")
5198+
.header("cookie", "kagi_session=test-session")
5199+
.header("accept", "application/vnd.kagi.stream");
5200+
then.status(200)
5201+
.header("content-type", "application/vnd.kagi.stream")
5202+
.delay(Duration::from_millis(200))
5203+
.body(concat!(
5204+
"hi:{\"v\":\"test\",\"trace\":\"trace-delayed\"}\0\n",
5205+
"thread.json:{\"id\":\"thread-delayed\",\"title\":\"Delayed test\",\"ack\":\"2026-05-01T00:00:00Z\",\"created_at\":\"2026-05-01T00:00:00Z\",\"saved\":false,\"shared\":false,\"branch_id\":\"00000000-0000-4000-0000-000000000000\",\"tag_ids\":[]}\0\n",
5206+
"new_message.json:{\"id\":\"msg-delayed\",\"thread_id\":\"thread-delayed\",\"created_at\":\"2026-05-01T00:00:00Z\",\"state\":\"done\",\"prompt\":\"Hello\",\"md\":\"delayed-ok\",\"documents\":[]}\0\n"
5207+
));
5208+
});
5209+
5210+
let _env_guard = lock_env();
5211+
let _base_url_env = set_env_var("KAGI_BASE_URL", &server.base_url());
5212+
let response = execute_assistant_prompt(
5213+
&AssistantPromptRequest {
5214+
query: "Hello".to_string(),
5215+
thread_id: None,
5216+
attachments: Vec::new(),
5217+
profile_id: None,
5218+
model: None,
5219+
lens_id: None,
5220+
internet_access: None,
5221+
personalizations: None,
5222+
},
5223+
"test-session",
5224+
)
5225+
.await
5226+
.expect("delayed assistant prompt should succeed");
5227+
5228+
assert_eq!(response.meta.trace.as_deref(), Some("trace-delayed"));
5229+
assert_eq!(response.message.markdown.as_deref(), Some("delayed-ok"));
5230+
}
5231+
51755232
#[test]
51765233
fn normalizes_custom_bang_trigger_and_redirect_rule() {
51775234
assert_eq!(

src/http.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ pub const KAGI_TRANSLATE_BASE_URL_ENV: &str = "KAGI_TRANSLATE_BASE_URL";
2121

2222
static CLIENT_20S: OnceLock<Client> = OnceLock::new();
2323
static CLIENT_30S: OnceLock<Client> = OnceLock::new();
24+
static CLIENT_ASSISTANT_STREAM: OnceLock<Client> = OnceLock::new();
2425

2526
/// Returns a shared HTTP client with a 20-second timeout.
2627
///
@@ -38,6 +39,30 @@ pub fn client_30s() -> Result<Client, KagiError> {
3839
cached_client(&CLIENT_30S, Duration::from_secs(30))
3940
}
4041

42+
/// Returns a shared HTTP client for Kagi Assistant streams.
43+
///
44+
/// Assistant responses can legitimately take longer than the short API deadline while the
45+
/// server continues streaming useful frames. Use connect and per-read timeouts instead of a
46+
/// total request timeout so long completions are not cut off after 30 seconds.
47+
///
48+
/// # Errors
49+
/// Returns `KagiError::Network` if the client cannot be constructed.
50+
pub fn client_assistant_stream() -> Result<Client, KagiError> {
51+
if let Some(client) = CLIENT_ASSISTANT_STREAM.get() {
52+
return Ok(client.clone());
53+
}
54+
55+
let client = Client::builder()
56+
.user_agent(USER_AGENT)
57+
.connect_timeout(Duration::from_secs(20))
58+
.read_timeout(Duration::from_secs(120))
59+
.build()
60+
.map_err(|error| KagiError::Network(format!("failed to build HTTP client: {error}")))?;
61+
62+
let _ = CLIENT_ASSISTANT_STREAM.set(client.clone());
63+
Ok(client)
64+
}
65+
4166
/// Maps a `reqwest::Error` to a domain-specific `KagiError`.
4267
///
4368
/// # Arguments

0 commit comments

Comments
 (0)