diff --git a/.gitignore b/.gitignore index 44bc046..5462307 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ target/ # Dependency locks (kept for reproducibility — remove this line if you prefer) # Cargo.lock +.claude/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e69388..f7f10d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to this project are documented here. --- +## [Unreleased] + +### Added + +- **`EndStreamReason::CursorOutOfRange`** and **`EndStreamReason::FileNotIndexed`** — split the previously-conflated `Error + "cursor_out_of_range"` emission into two typed reasons. Before, a cursor past EOF and a URI the daemon had never indexed both surfaced as `reason: error, error: "cursor_out_of_range"`; clients could not distinguish "user gave bad coordinates" from "daemon has nothing for this path." Now: + - `CursorOutOfRange` — the file is indexed but the cursor line is outside its range. Error message reports the actual line count. + - `FileNotIndexed` — the daemon has no record of the URI. Error message names the URI. Callers should upsert or reindex, then retry. + - `Error` remains for any other stream-terminating failure. Clients should branch on specific reasons first and fall through to `Error` for the rest. + + Non-breaking for the happy path (`BudgetReached`, `Exhausted`) and for the free-form `error` string. Clients that strictly matched `reason == Error` on both failure modes now need one extra arm — all v2.1.x CKB builds ship in lockstep so this lands as a coordinated change. + +--- + ## [2.1.0] — 2026-04-15 ### Added diff --git a/bindings/rust/src/daemon/session.rs b/bindings/rust/src/daemon/session.rs index 2d34da4..59748d2 100644 --- a/bindings/rust/src/daemon/session.rs +++ b/bindings/rust/src/daemon/session.rs @@ -1588,21 +1588,29 @@ impl Session { .map(|t| t.lines().count() as i32) }; let Some(line_count) = line_count_opt else { + // File URI is not in the daemon's index at all — distinct + // from a cursor past EOF. CKB-side surfaces a different + // message ("file not indexed, run a delta first") vs. the + // cursor-coord error, so we split the reason codes here + // instead of overloading `Error` with a magic string. let term = ServerMessage::EndStream { - reason: EndStreamReason::Error, + reason: EndStreamReason::FileNotIndexed, emitted: 0, total_candidates: 0, - error: Some("cursor_out_of_range".into()), + error: Some(format!("{file_uri} is not in the daemon index")), }; write_message(stream, &term).await?; return Ok(()); }; if cursor_position.start_line < 0 || cursor_position.start_line >= line_count { let term = ServerMessage::EndStream { - reason: EndStreamReason::Error, + reason: EndStreamReason::CursorOutOfRange, emitted: 0, total_candidates: 0, - error: Some("cursor_out_of_range".into()), + error: Some(format!( + "cursor line {} is outside {file_uri} ({} lines)", + cursor_position.start_line, line_count + )), }; write_message(stream, &term).await?; return Ok(()); diff --git a/bindings/rust/src/query_graph/types.rs b/bindings/rust/src/query_graph/types.rs index d01b7ea..d0447f4 100644 --- a/bindings/rust/src/query_graph/types.rs +++ b/bindings/rust/src/query_graph/types.rs @@ -595,6 +595,13 @@ pub enum ErrorCode { } /// Why a [`ServerMessage::EndStream`] terminated a context stream. +/// +/// `CursorOutOfRange` and `FileNotIndexed` were previously both +/// reported as `Error` with `"cursor_out_of_range"` in the free-form +/// error string; CKB and other clients could not distinguish "user +/// gave bad coordinates" from "daemon has nothing for this path." +/// The split reasons let clients show the correct message without +/// string-matching. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum EndStreamReason { @@ -602,7 +609,18 @@ pub enum EndStreamReason { BudgetReached, /// No more relevant candidates exist. Exhausted, - /// An error terminated the stream; see [`ServerMessage::EndStream::error`]. + /// The cursor position is outside the file's line count. The file + /// itself is indexed — the caller's coordinates are bad. + CursorOutOfRange, + /// The daemon has no record of `file_uri` in its index. Distinct + /// from [`CursorOutOfRange`]: the cursor coordinates are irrelevant + /// because the file hasn't been indexed at all. Callers should + /// upsert the file (or trigger a workspace reindex) and retry. + FileNotIndexed, + /// An error terminated the stream that is not captured by a more + /// specific reason. See [`ServerMessage::EndStream::error`] for + /// the free-form description. Clients should branch on specific + /// reasons first and fall through to `Error` for the rest. Error, } diff --git a/bindings/rust/tests/integration.rs b/bindings/rust/tests/integration.rs index 4c74459..5285bce 100644 --- a/bindings/rust/tests/integration.rs +++ b/bindings/rust/tests/integration.rs @@ -654,10 +654,70 @@ async fn stream_context_cursor_out_of_range_errors() { let frame = recv_stream_frame(&mut client).await.unwrap(); match frame { ServerMessage::EndStream { reason, error, .. } => { - assert_eq!(reason, EndStreamReason::Error); - assert_eq!(error.as_deref(), Some("cursor_out_of_range")); + assert_eq!(reason, EndStreamReason::CursorOutOfRange); + // Message carries the actual line count so callers can + // surface a useful error without parsing the reason string. + let msg = error.as_deref().unwrap_or(""); + assert!( + msg.contains("cursor line 9999") && msg.contains("1 lines"), + "unexpected error message: {msg:?}" + ); + } + other => panic!("expected EndStream(CursorOutOfRange), got {other:?}"), + } + + task.abort(); + let _ = task.await; +} + +/// A cursor against a URI the daemon has never seen must terminate with +/// `FileNotIndexed`, not `CursorOutOfRange`. The two were collapsed +/// onto `Error` + a free-form string before; splitting lets CKB show +/// "upsert the file first" vs. "your coordinates are bad." +#[tokio::test] +async fn stream_context_unknown_uri_reports_file_not_indexed() { + use lip_core::query_graph::types::EndStreamReason; + use lip_core::schema::OwnedRange; + + let dir = tempfile::tempdir().expect("tempdir"); + let socket = dir.path().join("lip_stream_unknown.sock"); + let daemon = LipDaemon::new(&socket); + let task = tokio::spawn(async move { daemon.run().await.ok() }); + tokio::time::sleep(Duration::from_millis(20)).await; + + let mut client = UnixStream::connect(&socket).await.expect("connect"); + + // Do NOT upsert anything. The daemon has no record of this URI. + send( + &mut client, + &ClientMessage::StreamContext { + file_uri: "lip://local/never/indexed.rs".into(), + cursor_position: OwnedRange { + start_line: 0, + start_char: 0, + end_line: 0, + end_char: 0, + }, + max_tokens: 4096, + model: None, + }, + ) + .await + .unwrap(); + + let frame = recv_stream_frame(&mut client).await.unwrap(); + match frame { + ServerMessage::EndStream { reason, error, .. } => { + assert_eq!(reason, EndStreamReason::FileNotIndexed); + assert!( + error + .as_deref() + .unwrap_or("") + .contains("not in the daemon index"), + "expected daemon-index error message, got {error:?}" + ); } - other => panic!("expected EndStream(error), got {other:?}"), + other => panic!("expected EndStream(FileNotIndexed), got {other:?}"), } task.abort(); diff --git a/tools/lip-cli/src/proto/scip.proto b/tools/lip-cli/src/proto/scip.proto index f312f08..0106348 100644 --- a/tools/lip-cli/src/proto/scip.proto +++ b/tools/lip-cli/src/proto/scip.proto @@ -44,9 +44,9 @@ message Document { message SymbolInformation { string symbol = 1; repeated string documentation = 3; - repeated Relationship relationships = 2; - Kind kind = 4; - string display_name = 5; + repeated Relationship relationships = 4; + Kind kind = 5; + string display_name = 6; } // Symbol kind — values prefixed with K_ to avoid conflict with other enums.