From 6c464d395ab57e13f07c472f8b559daec716ccf3 Mon Sep 17 00:00:00 2001 From: Lisa Date: Wed, 15 Apr 2026 22:43:30 +0200 Subject: [PATCH 1/2] feat: EndStreamReason::CursorOutOfRange + FileNotIndexed (#20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: EndStreamReason::CursorOutOfRange + FileNotIndexed Splits the previously-conflated stream-terminator failure mode. Both "cursor past EOF" and "URI not in index" emitted `reason: error, error: "cursor_out_of_range"` — clients couldn't distinguish "user gave bad coordinates" from "daemon has nothing for this path." Now: - `CursorOutOfRange` — indexed file, cursor past EOF. Error string reports line count. - `FileNotIndexed` — URI unknown to the daemon. Error string names the URI so callers know what to upsert. - `Error` remains as the catch-all. Clients should branch on specific reasons first. Happy-path reasons (BudgetReached, Exhausted) unchanged. Co-Authored-By: Claude Opus 4.6 (1M context) * chore: gitignore .claude/ harness state --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .gitignore | 1 + CHANGELOG.md | 13 +++++ bindings/rust/src/daemon/session.rs | 16 +++++-- bindings/rust/src/query_graph/types.rs | 20 +++++++- bindings/rust/tests/integration.rs | 66 ++++++++++++++++++++++++-- 5 files changed, 108 insertions(+), 8 deletions(-) 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(); From f1539c9546f9ca4eea8c879128ac34440a3a7d9f Mon Sep 17 00:00:00 2001 From: Lisa Date: Thu, 16 Apr 2026 07:42:38 +0200 Subject: [PATCH 2/2] fix(scip): align SymbolInformation field numbers with upstream SCIP proto relationships was field 2 (upstream: 4), kind was field 4 (upstream: 5), display_name was field 5 (upstream: 6). The mismatch caused a wire-type decode error (LengthDelimited vs Varint) when importing any index produced by a spec-compliant SCIP emitter. Co-Authored-By: Claude Opus 4.6 (1M context) --- tools/lip-cli/src/proto/scip.proto | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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.