Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ target/

# Dependency locks (kept for reproducibility — remove this line if you prefer)
# Cargo.lock
.claude/
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 12 additions & 4 deletions bindings/rust/src/daemon/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(());
Expand Down
20 changes: 19 additions & 1 deletion bindings/rust/src/query_graph/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -595,14 +595,32 @@ 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 {
/// Daemon emitted enough symbols to reach `max_tokens`.
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,
}

Expand Down
66 changes: 63 additions & 3 deletions bindings/rust/tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
6 changes: 3 additions & 3 deletions tools/lip-cli/src/proto/scip.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading