diff --git a/rust/lithe-agent-host/src/claude_desktop.rs b/rust/lithe-agent-host/src/claude_desktop.rs index 6b8cd1a7..e63933df 100644 --- a/rust/lithe-agent-host/src/claude_desktop.rs +++ b/rust/lithe-agent-host/src/claude_desktop.rs @@ -111,7 +111,10 @@ impl Presenter { "kind": "other", "status": "completed", }); - set_card(&mut replies, json!({"card": "nextStep", "actions": actions})); + set_card( + &mut replies, + json!({"card": "nextStep", "actions": actions}), + ); shown.push(replies); } shown @@ -128,16 +131,32 @@ impl Presenter { // Shown as the state of the card it withdraws, once the result confirms it. if let Some(task) = str_field(&input, "task_id") { let call = readable_call(update, "Withdrew a suggested task"); - self.task_changes.insert(id.clone(), TaskChange { task: task.to_owned(), state: "withdrawn", call }); + self.task_changes.insert( + id.clone(), + TaskChange { + task: task.to_owned(), + state: "withdrawn", + call, + }, + ); } self.absorbed.insert(id); Vec::new() } "mcp__ccd_session__start_session" if str_field(&input, "proposal_id").is_some() => { // Starting a proposed card: the card itself changes state. - let task = str_field(&input, "proposal_id").unwrap_or_default().to_owned(); + let task = str_field(&input, "proposal_id") + .unwrap_or_default() + .to_owned(); let call = readable_call(update, "Started a suggested task"); - self.task_changes.insert(id.clone(), TaskChange { task, state: "started", call }); + self.task_changes.insert( + id.clone(), + TaskChange { + task, + state: "started", + call, + }, + ); self.absorbed.insert(id); Vec::new() } @@ -205,7 +224,13 @@ impl Presenter { self.change_task(&change.task, state) } - fn retitle(&mut self, id: String, mut update: Value, title: &str, kind: &'static str) -> Vec { + fn retitle( + &mut self, + id: String, + mut update: Value, + title: &str, + kind: &'static str, + ) -> Vec { update["title"] = json!(title); update["kind"] = json!(kind); self.titles.insert(id, (title.to_owned(), kind)); @@ -242,7 +267,13 @@ pub(crate) fn carries_card_input(name: &str) -> bool { .is_some_and(|(server, _)| { matches!( server, - "ccd_turn" | "ccd_session" | "ccd_pr" | "ccd_directory" | "ccd_view" | "ccd_host" | "visualize" + "ccd_turn" + | "ccd_session" + | "ccd_pr" + | "ccd_directory" + | "ccd_view" + | "ccd_host" + | "visualize" ) }) } @@ -261,11 +292,19 @@ struct Described { impl Described { fn row(title: impl Into, kind: &'static str) -> Self { - Self { title: title.into(), kind, locations: Vec::new(), card: None } + Self { + title: title.into(), + kind, + locations: Vec::new(), + card: None, + } } fn card(title: impl Into, card: Value) -> Self { - Self { card: Some(card), ..Self::row(title, "other") } + Self { + card: Some(card), + ..Self::row(title, "other") + } } } @@ -289,14 +328,21 @@ fn describe(name: &str, input: &Value) -> Option { None, field("prompt"), // The model may only propose; a start the user asked for runs at once. - if field("initiation") == Some("own_initiative") { "proposed" } else { "started" }, + if field("initiation") == Some("own_initiative") { + "proposed" + } else { + "started" + }, ), ("ccd_session", "hand_off_to_session") => { let plan = field("remaining_plan"); - let summary = plan.and_then(|plan| plan.lines().map(str::trim).find(|line| !line.is_empty())); + let summary = + plan.and_then(|plan| plan.lines().map(str::trim).find(|line| !line.is_empty())); task_card(field("title")?, summary, plan, "handedOff") } - ("ccd_session", "read_widget_context") => Described::row("Read the widget's context", "read"), + ("ccd_session", "read_widget_context") => { + Described::row("Read the widget's context", "read") + } ("ccd_pr", "get_status") => Described::row("Checked the pull request status", "read"), ("ccd_pr", action @ ("bind_pr" | "unbind_pr" | "set_monitor" | "set_auto_merge")) => { let url = field("url"); @@ -315,12 +361,18 @@ fn describe(name: &str, input: &Value) -> Option { "autoMerge" => card["enabled"] = json!(input["enabled"] != false), _ => {} } - let title = url.map_or("Pull request".to_owned(), |url| format!("Pull request {url}")); + let title = url.map_or("Pull request".to_owned(), |url| { + format!("Pull request {url}") + }); Described::card(title, card) } ("ccd_directory", action @ ("change_directory" | "request_directory")) => { let path = field("path")?; - let action = if action == "change_directory" { "changed" } else { "requested" }; + let action = if action == "change_directory" { + "changed" + } else { + "requested" + }; Described::card( format!("Working folder {path}"), json!({"card": "directory", "action": action, "path": path}), @@ -331,20 +383,31 @@ fn describe(name: &str, input: &Value) -> Option { locations: vec![json!({"path": path})], ..Described::row(format!("Showed {path}"), "read") }, - None => Described::row(format!("Showed the {} pane", field("pane").unwrap_or("side")), "other"), + None => Described::row( + format!("Showed the {} pane", field("pane").unwrap_or("side")), + "other", + ), }, ("ccd_host", tool @ ("open_in_editor" | "reveal_path")) => { let path = field("path")?; - let verb = if tool == "open_in_editor" { "Opened" } else { "Revealed" }; + let verb = if tool == "open_in_editor" { + "Opened" + } else { + "Revealed" + }; let mut location = json!({"path": path}); if let Some(line) = input["line"].as_u64().filter(|line| *line > 0) { location["line"] = json!(line); } - Described { locations: vec![location], ..Described::row(format!("{verb} {path}"), "read") } + Described { + locations: vec![location], + ..Described::row(format!("{verb} {path}"), "read") + } } ("visualize", "show_widget") => { let title = humanize(field("title").unwrap_or("visualization")); - let code = field("widget_code").filter(|code| code.chars().count() <= WIDGET_CODE_LIMIT); + let code = + field("widget_code").filter(|code| code.chars().count() <= WIDGET_CODE_LIMIT); Described::card( format!("Visualization: {title}"), json!({"card": "widget", "title": title, "code": code}), @@ -388,9 +451,10 @@ fn describe(name: &str, input: &Value) -> Option { fn is_connector_id(server: &str) -> bool { let groups: Vec<_> = server.split('-').collect(); groups.len() == 5 - && groups.iter().zip([8, 4, 4, 4, 12]).all(|(group, len)| { - group.len() == len && group.chars().all(|c| c.is_ascii_hexdigit()) - }) + && groups + .iter() + .zip([8, 4, 4, 4, 12]) + .all(|(group, len)| group.len() == len && group.chars().all(|c| c.is_ascii_hexdigit())) } fn task_card(title: &str, summary: Option<&str>, prompt: Option<&str>, state: &str) -> Described { @@ -417,7 +481,10 @@ fn set_card(update: &mut Value, card: Value) { } fn str_field<'a>(input: &'a Value, key: &str) -> Option<&'a str> { - input[key].as_str().map(str::trim).filter(|value| !value.is_empty()) + input[key] + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) } /// The recap followed by the question, as separate paragraphs. @@ -451,7 +518,10 @@ fn next_step_actions(input: &Value) -> Option> { fn present_pr_created(mut update: Value) -> Value { const OPEN: &str = ""; const CLOSE: &str = ""; - let Some(text) = update["content"]["text"].as_str().filter(|text| text.contains(OPEN)) else { + let Some(text) = update["content"]["text"] + .as_str() + .filter(|text| text.contains(OPEN)) + else { return update; }; let mut out = String::with_capacity(text.len()); @@ -631,7 +701,10 @@ mod tests { assert_eq!(shown[0]["content"]["text"], "PR opened.\n\nMark it ready?"); assert_eq!(shown[1]["toolCallId"], "w#next"); assert_eq!(card(&shown[1])["card"], "nextStep"); - assert_eq!(card(&shown[1])["actions"], json!([{"label": "Yes", "message": "yes"}])); + assert_eq!( + card(&shown[1])["actions"], + json!([{"label": "Yes", "message": "yes"}]) + ); assert!(presenter.present("s", done("w")).is_empty()); let recap_only = presenter.present("s", call(WRAP_UP_TOOL, "v", json!({"recap": "Done."}))); @@ -651,7 +724,8 @@ mod tests { assert_eq!(statuses(&set), ["in_progress", "pending", "pending"]); assert!(presenter.present("s", done("p1")).is_empty()); - let advanced = one(presenter.present("s", call(UPDATE_PLAN_TOOL, "p2", json!({"advance": true})))); + let advanced = + one(presenter.present("s", call(UPDATE_PLAN_TOOL, "p2", json!({"advance": true})))); assert_eq!(statuses(&advanced), ["completed", "in_progress", "pending"]); let ticked = call(UPDATE_PLAN_TOOL, "p3", json!({"done": [2], "start": 3})); @@ -667,15 +741,28 @@ mod tests { assert_eq!(presenter.present("s", done("w")), vec![done("w")]); // An increment without a list to apply it to stays a tool call, now titled. let orphan = call(UPDATE_PLAN_TOOL, "p", json!({"advance": true})); - assert_eq!(one(presenter.present("t", orphan))["title"], "Updated the task list"); + assert_eq!( + one(presenter.present("t", orphan))["title"], + "Updated the task list" + ); let bash = call("Bash", "b", json!({"command": "ls"})); assert_eq!(presenter.present("s", bash.clone()), vec![bash]); let other_server = call("mcp__dbx__dbx_list_connections", "d", json!({})); - assert_eq!(presenter.present("s", other_server.clone()), vec![other_server]); + assert_eq!( + presenter.present("s", other_server.clone()), + vec![other_server] + ); // A claude.ai connector is named by its id; a server merely resembling one is not. - let connector = call("mcp__1a59c906-04da-521d-bda7-7f71b9f9e01c__read", "k", json!({})); + let connector = call( + "mcp__1a59c906-04da-521d-bda7-7f71b9f9e01c__read", + "k", + json!({}), + ); let connector = one(presenter.present("s", connector)); - assert_eq!((connector["title"].as_str(), connector["kind"].as_str()), (Some("Connector: read"), Some("read"))); + assert_eq!( + (connector["title"].as_str(), connector["kind"].as_str()), + (Some("Connector: read"), Some("read")) + ); let lookalike = call("mcp__1a59c906-04da-521d-bda7__read", "l", json!({})); assert_eq!(presenter.present("s", lookalike.clone()), vec![lookalike]); let text = json!({"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": "hi"}}); @@ -686,15 +773,22 @@ mod tests { fn chapters_pull_requests_folders_and_widgets_become_cards_with_readable_titles() { let mut presenter = Presenter::default(); let input = json!({"title": "Core", "summary": "Rust first"}); - let chapter = one(presenter.present("s", call("mcp__ccd_session__mark_chapter", "c", input))); + let chapter = + one(presenter.present("s", call("mcp__ccd_session__mark_chapter", "c", input))); assert_eq!(chapter["title"], "Chapter: Core"); - assert_eq!(card(&chapter), &json!({"card": "chapter", "title": "Core", "summary": "Rust first"})); + assert_eq!( + card(&chapter), + &json!({"card": "chapter", "title": "Core", "summary": "Rust first"}) + ); // Card calls keep their own results, so a failed call stays visible as one. assert_eq!(presenter.present("s", done("c")), vec![done("c")]); // A later update restating the adapter's title keeps the readable one. let restated = json!({"sessionUpdate": "tool_call_update", "toolCallId": "c", "title": "mcp__ccd_session__mark_chapter", "kind": "other"}); - assert_eq!(one(presenter.present("s", restated))["title"], "Chapter: Core"); + assert_eq!( + one(presenter.present("s", restated))["title"], + "Chapter: Core" + ); let input = json!({"url": "https://github.com/o/r/pull/31", "auto_fix": true}); let pr = one(presenter.present("s", call("mcp__ccd_pr__set_monitor", "p", input))); @@ -704,8 +798,14 @@ mod tests { assert_eq!(card(&pr)["autoFix"], true); let input = json!({"path": "/w/other"}); - let folder = one(presenter.present("s", call("mcp__ccd_directory__change_directory", "d", input))); - assert_eq!(card(&folder), &json!({"card": "directory", "action": "changed", "path": "/w/other"})); + let folder = one(presenter.present( + "s", + call("mcp__ccd_directory__change_directory", "d", input), + )); + assert_eq!( + card(&folder), + &json!({"card": "directory", "action": "changed", "path": "/w/other"}) + ); let input = json!({"title": "vcs_layers", "widget_code": ""}); let widget = one(presenter.present("s", call("mcp__visualize__show_widget", "v", input))); @@ -722,11 +822,20 @@ mod tests { assert_eq!(pane["locations"], json!([{"path": "notes/a.md"}])); let input = json!({"path": "/w/a.rs", "line": 12}); let editor = one(presenter.present("s", call("mcp__ccd_host__open_in_editor", "e", input))); - assert_eq!(editor["locations"], json!([{"path": "/w/a.rs", "line": 12}])); - let layout = one(presenter.present("s", call("mcp__ccd_window__get_window_layout", "l", json!({})))); + assert_eq!( + editor["locations"], + json!([{"path": "/w/a.rs", "line": 12}]) + ); + let layout = one(presenter.present( + "s", + call("mcp__ccd_window__get_window_layout", "l", json!({})), + )); assert_eq!(layout["title"], "Claude Desktop: get window layout"); assert_eq!(layout["kind"], "read"); - let shot = one(presenter.present("s", call("mcp__computer-use__app_screenshot", "x", json!({})))); + let shot = one(presenter.present( + "s", + call("mcp__computer-use__app_screenshot", "x", json!({})), + )); assert_eq!(shot["title"], "Computer use: app screenshot"); } @@ -734,13 +843,24 @@ mod tests { fn task_cards_follow_their_proposal_withdrawal_and_start() { let mut presenter = Presenter::default(); let input = json!({"title": "Fix", "tldr": "Why", "prompt": "Do it"}); - let proposed = one(presenter.present("s", call("mcp__ccd_session__spawn_task", "t1", input))); + let proposed = + one(presenter.present("s", call("mcp__ccd_session__spawn_task", "t1", input))); assert_eq!(card(&proposed)["state"], "proposed"); assert_eq!(card(&proposed)["prompt"], "Do it"); - presenter.present("s", result("t1", "Noted (position 1, task_id: task_9edecafe). A chip is showing")); + presenter.present( + "s", + result( + "t1", + "Noted (position 1, task_id: task_9edecafe). A chip is showing", + ), + ); // The withdrawal call itself is absorbed; its result restates the card. - let dismiss = call("mcp__ccd_session__dismiss_task", "x", json!({"task_id": "task_9edecafe"})); + let dismiss = call( + "mcp__ccd_session__dismiss_task", + "x", + json!({"task_id": "task_9edecafe"}), + ); assert!(presenter.present("s", dismiss).is_empty()); let withdrawn = one(presenter.present("s", result("x", "Task task_9edecafe withdrawn"))); assert_eq!(withdrawn["toolCallId"], "t1"); @@ -748,18 +868,39 @@ mod tests { let input = json!({"initiation": "own_initiative", "title": "Move", "prompt": "Go"}); presenter.present("s", call("mcp__ccd_session__start_session", "t2", input)); - presenter.present("s", result("t2", "Proposed \"Move\" (task_id: task_0f1ad1a7). The user sees a card")); - let start = call("mcp__ccd_session__start_session", "y", json!({"proposal_id": "task_0f1ad1a7"})); + presenter.present( + "s", + result( + "t2", + "Proposed \"Move\" (task_id: task_0f1ad1a7). The user sees a card", + ), + ); + let start = call( + "mcp__ccd_session__start_session", + "y", + json!({"proposal_id": "task_0f1ad1a7"}), + ); assert!(presenter.present("s", start).is_empty()); - assert_eq!(card(&one(presenter.present("s", done("y"))))["state"], "started"); + assert_eq!( + card(&one(presenter.present("s", done("y"))))["state"], + "started" + ); - let dismiss = call("mcp__ccd_session__dismiss_task", "z", json!({"task_id": "task_0f1ad1a7"})); + let dismiss = call( + "mcp__ccd_session__dismiss_task", + "z", + json!({"task_id": "task_0f1ad1a7"}), + ); presenter.present("s", dismiss); - let late = one(presenter.present("s", result("z", "Task task_0f1ad1a7 was already started"))); + let late = + one(presenter.present("s", result("z", "Task task_0f1ad1a7 was already started"))); assert_eq!(card(&late)["state"], "started"); let input = json!({"title": "Stats", "remaining_plan": "\nDone: x\nNext: y"}); - let handoff = one(presenter.present("s", call("mcp__ccd_session__hand_off_to_session", "h", input))); + let handoff = one(presenter.present( + "s", + call("mcp__ccd_session__hand_off_to_session", "h", input), + )); assert_eq!(card(&handoff)["state"], "handedOff"); assert_eq!(card(&handoff)["summary"], "Done: x"); } @@ -767,13 +908,27 @@ mod tests { #[test] fn failed_task_actions_stay_visible_and_progress_does_not_change_the_card() { let mut presenter = Presenter::default(); - presenter.present("s", call("mcp__ccd_session__spawn_task", "t1", json!({"title": "Fix", "prompt": "Do it"}))); + presenter.present( + "s", + call( + "mcp__ccd_session__spawn_task", + "t1", + json!({"title": "Fix", "prompt": "Do it"}), + ), + ); presenter.present("s", result("t1", "Noted (task_id: task_9edecafe).")); - let dismiss = call("mcp__ccd_session__dismiss_task", "x", json!({"task_id": "task_9edecafe"})); + let dismiss = call( + "mcp__ccd_session__dismiss_task", + "x", + json!({"task_id": "task_9edecafe"}), + ); assert!(presenter.present("s", dismiss).is_empty()); let progress = json!({"sessionUpdate": "tool_call_update", "toolCallId": "x", "status": "in_progress"}); - assert!(presenter.present("s", progress).is_empty(), "Progress must not withdraw the card"); + assert!( + presenter.present("s", progress).is_empty(), + "Progress must not withdraw the card" + ); let failed = json!({"sessionUpdate": "tool_call_update", "toolCallId": "x", "status": "failed", "content": [{"type": "content", "content": {"type": "text", "text": "Denied"}}]}); let shown = presenter.present("s", failed); @@ -783,21 +938,30 @@ mod tests { assert_eq!(shown[0]["title"], "Withdrew a suggested task"); assert_eq!(shown[1]["status"], "failed"); - let start = call("mcp__ccd_session__start_session", "y", json!({"proposal_id": "task_9edecafe"})); + let start = call( + "mcp__ccd_session__start_session", + "y", + json!({"proposal_id": "task_9edecafe"}), + ); assert!(presenter.present("s", start).is_empty()); - let failed = json!({"sessionUpdate": "tool_call_update", "toolCallId": "y", "status": "failed"}); - assert_eq!(presenter.present("s", failed)[0]["title"], "Started a suggested task"); + let failed = + json!({"sessionUpdate": "tool_call_update", "toolCallId": "y", "status": "failed"}); + assert_eq!( + presenter.present("s", failed)[0]["title"], + "Started a suggested task" + ); } #[test] fn created_pull_request_tags_become_links() { let mut presenter = Presenter::default(); - let chunk = |text: &str| { - json!({"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": text}}) - }; + let chunk = |text: &str| json!({"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": text}}); let tagged = chunk("Opened.\nhttps://github.com/o/r/pull/7\n"); let shown = one(presenter.present("s", tagged)); - assert_eq!(shown["content"]["text"], "Opened.\n[Pull request #7](https://github.com/o/r/pull/7)\n"); + assert_eq!( + shown["content"]["text"], + "Opened.\n[Pull request #7](https://github.com/o/r/pull/7)\n" + ); // Anything but a URL is left as the model wrote it. let odd = chunk("not a url"); assert_eq!(one(presenter.present("s", odd.clone())), odd); diff --git a/rust/lithe-agent-host/src/claude_earlier_history.rs b/rust/lithe-agent-host/src/claude_earlier_history.rs index 288ef796..ac9123dd 100644 --- a/rust/lithe-agent-host/src/claude_earlier_history.rs +++ b/rust/lithe-agent-host/src/claude_earlier_history.rs @@ -334,9 +334,13 @@ fn title(name: &str, input: &Value) -> String { fn bounded(value: &Value, limit: usize) -> Value { match value { Value::String(text) => Value::String(truncate_to(text, limit)), - Value::Array(items) => Value::Array(items.iter().map(|item| bounded(item, limit)).collect()), + Value::Array(items) => { + Value::Array(items.iter().map(|item| bounded(item, limit)).collect()) + } Value::Object(map) => Value::Object( - map.iter().map(|(k, v)| (k.clone(), bounded(v, limit))).collect(), + map.iter() + .map(|(k, v)| (k.clone(), bounded(v, limit))) + .collect(), ), other => other.clone(), } @@ -477,9 +481,16 @@ mod tests { let updates = earlier_updates(dir.path(), &cwd, "abc-1"); let mut presenter = claude_desktop::Presenter::default(); let shown = presenter.present("s", updates[0].clone()); - assert_eq!(shown[0]["_meta"]["lithe"]["claudeDesktop"]["code"], widget.as_str()); + assert_eq!( + shown[0]["_meta"]["lithe"]["claudeDesktop"]["code"], + widget.as_str() + ); let write = updates[1]["rawInput"]["content"].as_str().unwrap(); - assert_eq!(write.chars().count(), TEXT_LIMIT + 1, "Ordinary inputs stay previews"); + assert_eq!( + write.chars().count(), + TEXT_LIMIT + 1, + "Ordinary inputs stay previews" + ); } #[test] @@ -495,7 +506,11 @@ mod tests { boundary(), ]); let updates = earlier_updates(dir.path(), &cwd, "abc-1"); - assert_eq!(updates.len(), 2, "only the inline base64 image within the limit is replayed"); + assert_eq!( + updates.len(), + 2, + "only the inline base64 image within the limit is replayed" + ); assert_eq!(updates[1]["sessionUpdate"], "user_message_chunk"); assert_eq!(updates[1]["content"]["type"], "image"); assert_eq!(updates[1]["content"]["data"], "/9j/4A=="); diff --git a/rust/lithe-agent-host/src/edit_review.rs b/rust/lithe-agent-host/src/edit_review.rs index c6fc8a42..ca62874a 100644 --- a/rust/lithe-agent-host/src/edit_review.rs +++ b/rust/lithe-agent-host/src/edit_review.rs @@ -115,7 +115,9 @@ fn claude_write(update: &Value, reported: Option<&[DiffEvidence]>) -> Option ReversePlan { (true, Some(old), None) => Some(old.to_owned()), (true, _, _) => return conflict(ConflictReason::Changed), // Creation: remove only the exact created content. - (false, None, Some(current)) if canonical(¤t).0 == canonical(&diff.new_text).0 => { + (false, None, Some(current)) + if canonical(¤t).0 == canonical(&diff.new_text).0 => + { None } (false, None, _) => return conflict(ConflictReason::Changed), diff --git a/rust/lithe-agent-host/src/edit_review/tests.rs b/rust/lithe-agent-host/src/edit_review/tests.rs index c4c35ae3..c15e37dc 100644 --- a/rust/lithe-agent-host/src/edit_review/tests.rs +++ b/rust/lithe-agent-host/src/edit_review/tests.rs @@ -51,10 +51,12 @@ fn shared_evidence_fixture_matches_host_annotation() { #[test] fn shared_reverse_plan_fixture_matches_planner() { let fixture = fixture(); - for case in fixture["reversePlan"].as_array().expect("reverse plan cases") { + for case in fixture["reversePlan"] + .as_array() + .expect("reverse plan cases") + { let name = case["name"].as_str().unwrap_or_default(); - let request: ReverseRequest = - serde_json::from_value(expand(&case["request"])).expect(name); + let request: ReverseRequest = serde_json::from_value(expand(&case["request"])).expect(name); let plan = serde_json::to_value(reverse_plan(&request)).expect(name); assert_eq!(plan, expand(&case["expected"]), "{name}"); } @@ -62,9 +64,14 @@ fn shared_reverse_plan_fixture_matches_planner() { #[test] fn content_beyond_the_entry_limit_marks_every_diff_incomplete() { - let mut content = vec![json!({"type": "diff", "path": "a.txt", "oldText": "a", "newText": "b"})]; - content.extend((0..CONTENT_LIMIT).map(|_| json!({"type": "content", "content": {"type": "text", "text": "x"}}))); - let diffs = evidence(&json!({"sessionUpdate": "tool_call", "content": content})).expect("diffs"); + let mut content = + vec![json!({"type": "diff", "path": "a.txt", "oldText": "a", "newText": "b"})]; + content.extend( + (0..CONTENT_LIMIT) + .map(|_| json!({"type": "content", "content": {"type": "text", "text": "x"}})), + ); + let diffs = + evidence(&json!({"sessionUpdate": "tool_call", "content": content})).expect("diffs"); assert_eq!(diffs.len(), 1); assert!(diffs[0].truncated && !diffs[0].revertible); } @@ -99,7 +106,9 @@ fn canonical_match_maps_multibyte_offsets_back_to_the_original() { }; assert_eq!( reverse_plan(&request), - ReversePlan::Write { text: "😀\r前\r\n旧\r\n后".into() } + ReversePlan::Write { + text: "😀\r前\r\n旧\r\n后".into() + } ); } diff --git a/rust/lithe-agent-host/src/lib.rs b/rust/lithe-agent-host/src/lib.rs index 60ffd7ed..9d75d9ae 100644 --- a/rust/lithe-agent-host/src/lib.rs +++ b/rust/lithe-agent-host/src/lib.rs @@ -14,10 +14,10 @@ mod claude_desktop; mod claude_earlier_history; mod claude_subscription; pub mod cli_update; -pub mod edit_review; mod codex_context; mod codex_history; mod codex_retry; +pub mod edit_review; pub mod environment; pub mod hooks; pub mod install; @@ -25,8 +25,8 @@ pub mod instructions; pub mod mcp_probe; mod model_call_statistics; mod model_calls; -pub mod next_turn_config; mod model_proxy; +pub mod next_turn_config; mod prompt; mod prompt_retry; mod session_defaults; diff --git a/rust/lithe-agent-host/src/model_call_statistics.rs b/rust/lithe-agent-host/src/model_call_statistics.rs index 7de176c3..63bf0ff0 100644 --- a/rust/lithe-agent-host/src/model_call_statistics.rs +++ b/rust/lithe-agent-host/src/model_call_statistics.rs @@ -140,7 +140,11 @@ pub(crate) struct StatisticsTracker { impl StatisticsTracker { /// Contribution of a call attributed to `session_id`. Unattributed calls /// are dropped by both products, so they keep no per-session memory. - pub(crate) fn contribution(&self, session_id: Option<&str>, call: &ModelCall) -> ModelCallStatistics { + pub(crate) fn contribution( + &self, + session_id: Option<&str>, + call: &ModelCall, + ) -> ModelCallStatistics { let Some(session_id) = session_id else { return ModelCallStatistics::of_call(call, &mut false); }; @@ -180,7 +184,9 @@ mod tests { scaled_to_usage: context["scaledToUsage"].as_bool().unwrap(), }); ModelCall { - api: serde_json::from_value::(value["api"].clone()).unwrap().0, + api: serde_json::from_value::(value["api"].clone()) + .unwrap() + .0, model: None, status: value["status"].as_u64().unwrap() as u16, streaming: value["streaming"].as_bool().unwrap_or(false), @@ -241,10 +247,14 @@ mod tests { #[test] fn tool_memory_is_per_session() { let tracker = StatisticsTracker::default(); - let tools = call(&serde_json::json!({"api":"responses","status":200,"durationMs":1, - "context":{"systemPromptTokens":1,"toolTokens":1,"mcpToolTokens":0,"messageTokens":1,"toolCount":1,"mcpToolCount":0,"scaledToUsage":false}})); - let toolless = call(&serde_json::json!({"api":"responses","status":200,"durationMs":1, - "context":{"systemPromptTokens":1,"toolTokens":0,"mcpToolTokens":0,"messageTokens":9,"toolCount":0,"mcpToolCount":0,"scaledToUsage":false}})); + let tools = call( + &serde_json::json!({"api":"responses","status":200,"durationMs":1, + "context":{"systemPromptTokens":1,"toolTokens":1,"mcpToolTokens":0,"messageTokens":1,"toolCount":1,"mcpToolCount":0,"scaledToUsage":false}}), + ); + let toolless = call( + &serde_json::json!({"api":"responses","status":200,"durationMs":1, + "context":{"systemPromptTokens":1,"toolTokens":0,"mcpToolTokens":0,"messageTokens":9,"toolCount":0,"mcpToolCount":0,"scaledToUsage":false}}), + ); assert!(tracker.contribution(Some("a"), &tools).context.is_some()); assert!(tracker.contribution(Some("a"), &toolless).context.is_none()); assert!(tracker.contribution(Some("b"), &toolless).context.is_some()); diff --git a/rust/lithe-agent-host/src/next_turn_config.rs b/rust/lithe-agent-host/src/next_turn_config.rs index 4ca601aa..2dcbe631 100644 --- a/rust/lithe-agent-host/src/next_turn_config.rs +++ b/rust/lithe-agent-host/src/next_turn_config.rs @@ -148,9 +148,10 @@ pub fn catalog(options: &[SessionConfigOption]) -> Vec { return None; }; let choices = match &select.options { - SessionConfigSelectOptions::Ungrouped(values) => { - values.iter().map(|value| value.value.0.to_string()).collect() - } + SessionConfigSelectOptions::Ungrouped(values) => values + .iter() + .map(|value| value.value.0.to_string()) + .collect(), SessionConfigSelectOptions::Grouped(groups) => groups .iter() .flat_map(|group| group.options.iter()) @@ -215,7 +216,11 @@ mod tests { } } }; - assert_eq!(Value::Array(sent), case["expected"]["requests"], "{name} requests"); + assert_eq!( + Value::Array(sent), + case["expected"]["requests"], + "{name} requests" + ); assert_eq!( failure.map(|failure| serde_json::to_value(failure).unwrap()), case["expected"]["failure"].as_str().map(Value::from), diff --git a/rust/lithe-agent-host/src/skills.rs b/rust/lithe-agent-host/src/skills.rs index ddc17a69..2b7ba6f5 100644 --- a/rust/lithe-agent-host/src/skills.rs +++ b/rust/lithe-agent-host/src/skills.rs @@ -122,7 +122,10 @@ fn execute_at( .as_ref() .is_some_and(|p| !p.is_absolute() || !p.is_dir()) || !claude.is_absolute() - || request.codex_home.as_ref().is_some_and(|p| !p.is_absolute()) + || request + .codex_home + .as_ref() + .is_some_and(|p| !p.is_absolute()) { return Err(Error::Invalid( "The skill workspace and configuration directory must be absolute directories.".into(), @@ -153,7 +156,12 @@ fn execute_at( .enable_all() .build() .map_err(|_| Error::Failed("Skill discovery could not start.".into()))?; - let result = runtime.block_on(CodexHistory::skills(cli.path, cwd.to_path_buf(), request.codex_home.clone(), cancelled)); + let result = runtime.block_on(CodexHistory::skills( + cli.path, + cwd.to_path_buf(), + request.codex_home.clone(), + cancelled, + )); check(cancelled)?; let value = result.map_err(|_| { Error::Failed("Codex skill discovery failed. Check the CLI and retry.".into()) diff --git a/rust/lithe-agent-host/src/skills/tests.rs b/rust/lithe-agent-host/src/skills/tests.rs index 3c89d2a7..29d2e433 100644 --- a/rust/lithe-agent-host/src/skills/tests.rs +++ b/rust/lithe-agent-host/src/skills/tests.rs @@ -487,12 +487,20 @@ async fn native_discovery_uses_the_selected_account_directory() { fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap(); tokio::time::timeout( Duration::from_secs(5), - CodexHistory::skills(executable, fixture.0.join("project"), Some(home.clone()), &|| false), + CodexHistory::skills( + executable, + fixture.0.join("project"), + Some(home.clone()), + &|| false, + ), ) .await .unwrap() .unwrap(); - assert_eq!(fs::read_to_string(fixture.0.join("home.log")).unwrap(), home.to_str().unwrap()); + assert_eq!( + fs::read_to_string(fixture.0.join("home.log")).unwrap(), + home.to_str().unwrap() + ); } #[cfg(unix)] diff --git a/rust/lithe-agent-host/src/tests.rs b/rust/lithe-agent-host/src/tests.rs index 07aa4c6d..b6f31cbc 100644 --- a/rust/lithe-agent-host/src/tests.rs +++ b/rust/lithe-agent-host/src/tests.rs @@ -2466,9 +2466,15 @@ async fn next_turn_configuration_applies_choices_and_reports_one_result() { assert_eq!(request["params"]["value"], "model-b"); harness .agent - .reply(&request, json!({ "configOptions": configured["configOptions"] })) + .reply( + &request, + json!({ "configOptions": configured["configOptions"] }), + ) .await; - assert_eq!(serde_json::to_value(harness.event().await).unwrap(), configured); + assert_eq!( + serde_json::to_value(harness.event().await).unwrap(), + configured + ); harness.send(next["command"].clone()); let request = harness.agent.expect("session/set_config_option").await; @@ -2479,7 +2485,10 @@ async fn next_turn_configuration_applies_choices_and_reports_one_result() { let failed = serde_json::to_value(harness.event().await).unwrap(); assert_eq!(failed["kind"], "nextTurnConfigured"); assert_eq!(failed["failure"], "requestFailed"); - assert!(failed.get("configOptions").is_none(), "no request succeeded"); + assert!( + failed.get("configOptions").is_none(), + "no request succeeded" + ); assert!(failed["message"].as_str().unwrap().contains("Rejected")); assert_eq!(harness.stop().await, Ok(())); } diff --git a/rust/lithe-core/src/git/authentication_failure.rs b/rust/lithe-core/src/git/authentication_failure.rs new file mode 100644 index 00000000..b45bda14 --- /dev/null +++ b/rust/lithe-core/src/git/authentication_failure.rs @@ -0,0 +1,201 @@ +//! Locale-independent recognition of Git remote authentication failures. +//! +//! Every Git process Core starts runs with `LC_ALL=C` and +//! `GIT_TERMINAL_PROMPT=0` (see `lithe_git_host::configuration::configure` and +//! the remote spawn path), so Git, its HTTP transport and OpenSSH print fixed +//! English diagnostics whatever the user's system language is. Matching those +//! fixed markers is therefore stable, and hosts receive a code instead of +//! matching stderr themselves. + +use super::GitCommandResponse; +use crate::protocol::{CoreError, ErrorCode}; + +/// Fixed C-locale fragments that mean the remote refused the credentials or +/// none could be obtained. Compared case-sensitively against Git stderr. +const MARKERS: &[&str] = &[ + // HTTP(S): wrong or revoked credentials. + "Authentication failed", + "Invalid username or password", + "HTTP Basic: Access denied", + "The requested URL returned error: 401", + "The requested URL returned error: 403", + // HTTP(S): no credential available and prompting is disabled. + "could not read Username", + "could not read Password", + "terminal prompts disabled", + // SSH: key or agent rejected. + "Permission denied (publickey", + "Permission denied (password", + "Permission denied (keyboard-interactive", + // Hosting service refused the authenticated account. + "Permission to ", +]; + +/// Returns whether `text` carries a C-locale Git authentication failure marker. +pub(super) fn is_authentication_failure(text: &str) -> bool { + text.lines().any(|line| { + MARKERS.iter().any(|marker| match *marker { + // "Permission to denied to ." is only meaningful as a whole. + "Permission to " => line.contains(marker) && line.contains(" denied to "), + _ => line.contains(marker), + }) + }) +} + +/// Upgrades a failed `git.write` response to `git_authentication_failed`. +/// +/// Only failures that carry no more specific category are changed: a response +/// with a non-zero exit and no `operationError` gains one whose message is the +/// first stderr line, and a generic `process_failed` operation error keeps its +/// message and details under the new code. Successful responses are untouched. +pub(super) fn classify(response: &mut GitCommandResponse) { + let failed = response.exit_code != 0 || response.operation_error.is_some(); + if !failed { + return; + } + match &mut response.operation_error { + Some(error) => { + if matches!(error.code, ErrorCode::ProcessFailed) + && (is_authentication_failure(&error.message) + || error + .details + .as_deref() + .is_some_and(is_authentication_failure) + || response + .invocations + .iter() + .any(|invocation| is_authentication_failure(&invocation.stderr))) + { + error.code = ErrorCode::GitAuthenticationFailed; + } + } + None => { + if is_authentication_failure(&response.stderr) { + let message = response + .stderr + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .unwrap_or("Git authentication failed") + .to_string(); + response.operation_error = Some( + CoreError::new(ErrorCode::GitAuthenticationFailed, message) + .with_details(response.output.clone()), + ); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn failed(stderr: &str) -> GitCommandResponse { + super::super::GitProcessOutput { + stdout: Vec::new(), + stderr: stderr.as_bytes().to_vec(), + exit_code: 128, + } + .into_command_response(&["push".to_string()]) + } + + #[test] + fn recognises_http_and_ssh_failure_samples() { + for sample in [ + "remote: Invalid username or password.\nfatal: Authentication failed for 'https://example.com/a.git/'\n", + "fatal: could not read Username for 'https://example.com': terminal prompts disabled\n", + "fatal: unable to access 'https://example.com/a.git/': The requested URL returned error: 403\n", + "remote: Permission to a/b.git denied to someone.\nfatal: unable to access 'https://github.com/a/b.git/': The requested URL returned error: 403\n", + "git@github.com: Permission denied (publickey).\nfatal: Could not read from remote repository.\n\nPlease make sure you have the correct access rights\nand the repository exists.\n", + "user@host: Permission denied (publickey,password).\r\nfatal: Could not read from remote repository.\r\n", + ] { + assert!(is_authentication_failure(sample), "{sample}"); + } + } + + #[test] + fn shared_fixture_samples_classify_as_documented() { + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/git/authentication-failure-v1.json" + )) + .unwrap(); + for (key, expected) in [("failures", true), ("unrelated", false)] { + for sample in fixture[key].as_array().unwrap() { + let mut response = failed(sample["stderr"].as_str().unwrap()); + classify(&mut response); + let code = response + .operation_error + .map(|error| serde_json::to_value(error).unwrap()["code"].clone()); + assert_eq!( + code == Some(fixture["errorCode"].clone()), + expected, + "{}", + sample["name"] + ); + } + } + } + + #[test] + fn ignores_unrelated_failures() { + for sample in [ + "fatal: unable to access 'https://example.com/': Could not resolve host: example.com\n", + "fatal: repository 'https://example.com/a.git/' not found\n", + "ssh: connect to host example.com port 22: Connection timed out\nfatal: Could not read from remote repository.\n", + "! [rejected] main -> main (non-fast-forward)\n", + "error: Permission to write denied\n", + ] { + assert!(!is_authentication_failure(sample), "{sample}"); + } + } + + #[test] + fn failed_exit_gains_the_stable_code() { + let mut response = + failed("fatal: Authentication failed for 'https://example.com/a.git/'\n"); + classify(&mut response); + let error = response.operation_error.expect("operation error"); + assert!(matches!(error.code, ErrorCode::GitAuthenticationFailed)); + assert_eq!( + error.message, + "fatal: Authentication failed for 'https://example.com/a.git/'" + ); + assert!(error.details.unwrap().contains("Authentication failed")); + let json = + serde_json::to_value(CoreError::new(ErrorCode::GitAuthenticationFailed, "x")).unwrap(); + assert_eq!(json["code"], "git_authentication_failed"); + } + + #[test] + fn generic_process_failure_is_upgraded_and_specific_ones_are_kept() { + let mut response = failed("git@h: Permission denied (publickey).\n"); + response.operation_error = Some( + CoreError::new(ErrorCode::ProcessFailed, "Some Fetch operations failed") + .with_details("origin: git@h: Permission denied (publickey)."), + ); + classify(&mut response); + let error = response.operation_error.unwrap(); + assert!(matches!(error.code, ErrorCode::GitAuthenticationFailed)); + assert_eq!(error.message, "Some Fetch operations failed"); + + let mut response = failed("fatal: Authentication failed\n"); + response.operation_error = Some(CoreError::new(ErrorCode::InvalidRequest, "stale")); + classify(&mut response); + assert!(matches!( + response.operation_error.unwrap().code, + ErrorCode::InvalidRequest + )); + } + + #[test] + fn success_and_other_failures_are_untouched() { + let mut ok = failed(""); + ok.exit_code = 0; + classify(&mut ok); + assert!(ok.operation_error.is_none()); + let mut other = failed("fatal: not a git repository\n"); + classify(&mut other); + assert!(other.operation_error.is_none()); + } +} diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs index 37c002b5..3f3f261d 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -2,6 +2,7 @@ // The initial projection/routing IR is deliberately not command- or host-facing // until both native products can consume the same versioned contract. +mod authentication_failure; mod branch_names; pub(crate) mod catalog; mod change_targets; @@ -1136,7 +1137,9 @@ fn unstage_paths(root: &str, paths: &[String]) -> Result Result { - with_git_invocation_trace(|| write_with_trace(request)) + let mut response = with_git_invocation_trace(|| write_with_trace(request))?; + authentication_failure::classify(&mut response); + Ok(response) } fn write_with_trace(request: GitWriteRequest) -> Result { diff --git a/rust/lithe-core/src/lsp/interface/client.rs b/rust/lithe-core/src/lsp/interface/client.rs index 8d4166f6..ec21fa27 100644 --- a/rust/lithe-core/src/lsp/interface/client.rs +++ b/rust/lithe-core/src/lsp/interface/client.rs @@ -69,6 +69,7 @@ pub(super) fn client_initialize_for_plugin( "references": { "dynamicRegistration": true }, "rename": { "dynamicRegistration": true }, "formatting": { "dynamicRegistration": true }, + "rangeFormatting": { "dynamicRegistration": true }, "inlayHint": { "dynamicRegistration": true }, "semanticTokens": { "dynamicRegistration": true, @@ -748,6 +749,15 @@ pub(super) fn client_apply_server_message_for_plugin( ), _ => None, }; + if let Some(options) = result + .get("capabilities") + .and_then(|c| c.get("completionProvider")) + .filter(|options| options.is_object()) + { + state + .completion_providers + .insert(String::new(), options.clone()); + } if let Some(options) = result .get("capabilities") .and_then(|c| c.get("semanticTokensProvider")) @@ -767,6 +777,7 @@ pub(super) fn client_apply_server_message_for_plugin( state.shutdown_requested = false; state.server_capabilities.clear(); state.semantic_token_providers.clear(); + state.completion_providers.clear(); state.text_document_sync = LspTextDocumentSyncKind::Full; state.open_documents.clear(); state.diagnostics.clear(); @@ -1045,6 +1056,7 @@ fn validate_lsp_method(method: &str) -> Result<(), CoreError> { | "textDocument/references" | "textDocument/rename" | "textDocument/formatting" + | "textDocument/rangeFormatting" | "textDocument/inlayHint" | "textDocument/semanticTokens/full" | "textDocument/foldingRange" @@ -1117,13 +1129,12 @@ fn feature_request_params(request: &ClientFeatureRequest) -> Result Ok(json!({ "textDocument": text_document, - "options": { - "tabSize": 4, - "insertSpaces": true, - "trimTrailingWhitespace": true, - "insertFinalNewline": true, - "trimFinalNewlines": true - } + "options": formatting_options_json(request.formatting_options.as_ref())? + })), + "textDocument/rangeFormatting" => Ok(json!({ + "textDocument": text_document, + "range": lsp_range_json(required_range(request)?), + "options": formatting_options_json(request.formatting_options.as_ref())? })), "textDocument/inlayHint" => Ok(json!({ "textDocument": text_document, @@ -1176,6 +1187,44 @@ fn required_position(request: &ClientFeatureRequest) -> Result) -> Result { + let mut options = json!({ + "tabSize": 4, + "insertSpaces": true, + "trimTrailingWhitespace": true, + "insertFinalNewline": true, + "trimFinalNewlines": true + }); + let Some(supplied) = supplied.filter(|value| !value.is_null()) else { + return Ok(options); + }; + let invalid = || CoreError::new(ErrorCode::InvalidRequest, "Invalid formatting options"); + let supplied = supplied.as_object().ok_or_else(invalid)?; + for (key, value) in supplied { + let valid = match key.as_str() { + "tabSize" => value + .as_u64() + .is_some_and(|size| (1..=1024).contains(&size)), + "insertSpaces" + | "trimTrailingWhitespace" + | "insertFinalNewline" + | "trimFinalNewlines" => value.is_boolean(), + _ => value.is_boolean() || value.is_string() || value.is_i64() || value.is_u64(), + }; + if !valid { + return Err(invalid().with_details(key.clone())); + } + options[key] = value.clone(); + } + Ok(options) +} + fn required_range(request: &ClientFeatureRequest) -> Result { request.range.ok_or_else(|| { CoreError::new( @@ -1334,7 +1383,7 @@ fn lsp_feature_result_for_method(method: Option<&str>, result: Option<&Value>) - Some("textDocument/rename") => Some(json!({ "changes": parse_workspace_edit(result) })), - Some("textDocument/formatting") => Some(json!({ + Some("textDocument/formatting" | "textDocument/rangeFormatting") => Some(json!({ "edits": result .as_array() .map(|edits| edits.iter().filter_map(parse_lsp_text_edit_value).collect::>()) @@ -1847,6 +1896,12 @@ fn feature_names_from_capabilities(capabilities: &Value) -> Vec { "documentFormattingProvider", "formatting", ); + add_capability( + &mut values, + capabilities, + "documentRangeFormattingProvider", + "rangeFormatting", + ); add_capability(&mut values, capabilities, "inlayHintProvider", "inlayHints"); add_capability( &mut values, @@ -1973,6 +2028,18 @@ fn apply_dynamic_registration(state: &mut LspClientState, message: &Value) { } continue; } + if registration.get("method").and_then(Value::as_str) == Some("textDocument/completion") { + if let (Some(id), Some(options)) = ( + registration.get("id").and_then(Value::as_str), + registration + .get("registerOptions") + .filter(|options| options.is_object()), + ) { + state + .completion_providers + .insert(id.to_string(), options.clone()); + } + } if let Some(feature) = registration .get("method") .and_then(Value::as_str) @@ -2024,6 +2091,11 @@ fn apply_dynamic_unregistration(state: &mut LspClientState, message: &Value) { } continue; } + if unregistration.get("method").and_then(Value::as_str) == Some("textDocument/completion") { + if let Some(id) = unregistration.get("id").and_then(Value::as_str) { + state.completion_providers.remove(id); + } + } if let Some(feature) = unregistration .get("method") .and_then(Value::as_str) @@ -2036,6 +2108,42 @@ fn apply_dynamic_unregistration(state: &mut LspClientState, message: &Value) { } } +/// Completion trigger and all-commit characters the session's providers declared. +/// +/// Returns `{ "triggerCharacters": [...], "allCommitCharacters": [...] }`, each +/// list the de-duplicated union over the static capability and dynamic +/// registrations in registration order, or `None` while the session offers no +/// completion. Entries that are not non-empty strings are dropped. +pub(super) fn completion_options(state: &LspClientState) -> Option { + if !state + .server_capabilities + .iter() + .any(|feature| feature == "completion") + { + return None; + } + let collect = |key: &str| { + let mut values: Vec = Vec::new(); + for options in state.completion_providers.values() { + for value in options + .get(key) + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .filter(|value| !value.is_empty()) + { + insert_unique(&mut values, value); + } + } + values + }; + Some(json!({ + "triggerCharacters": collect("triggerCharacters"), + "allCommitCharacters": collect("allCommitCharacters"), + })) +} + fn feature_name_for_method(method: &str) -> Option<&'static str> { match method { "textDocument/definition" => Some("definition"), @@ -2047,6 +2155,7 @@ fn feature_name_for_method(method: &str) -> Option<&'static str> { "textDocument/completion" => Some("completion"), "textDocument/rename" => Some("rename"), "textDocument/formatting" => Some("formatting"), + "textDocument/rangeFormatting" => Some("rangeFormatting"), "textDocument/inlayHint" => Some("inlayHints"), "textDocument/foldingRange" => Some("foldingRanges"), "textDocument/codeLens" => Some("codeLens"), @@ -2205,3 +2314,146 @@ mod semantic_tests { assert!(refresh.messages[0].contains("result")); } } + +#[cfg(test)] +mod formatting_and_completion_tests { + use super::*; + + fn request(method: &str, options: Option, with_range: bool) -> ClientFeatureRequest { + ClientFeatureRequest { + state: LspClientState::default(), + uri: "file:///w/a.c".to_string(), + method: method.to_string(), + position: None, + new_name: None, + range: with_range.then(|| LspRange { + start: LspPosition { + line: 1, + utf16_column: 2, + }, + end: LspPosition { + line: 3, + utf16_column: 0, + }, + }), + diagnostics: Vec::new(), + completion_context: None, + completion_item: None, + code_action: None, + command: None, + formatting_options: options, + } + } + + #[test] + fn formatting_keeps_defaults_without_caller_options() { + let params = + feature_request_params(&request("textDocument/formatting", None, false)).unwrap(); + assert_eq!( + params["options"], + json!({"tabSize": 4, "insertSpaces": true, "trimTrailingWhitespace": true, + "insertFinalNewline": true, "trimFinalNewlines": true}) + ); + } + + #[test] + fn formatting_and_range_formatting_merge_caller_options() { + let options = + json!({"tabSize": 2, "insertSpaces": false, "trimFinalNewlines": false, "custom": "x"}); + let full = feature_request_params(&request( + "textDocument/formatting", + Some(options.clone()), + false, + )) + .unwrap(); + assert_eq!(full["options"]["tabSize"], 2); + assert_eq!(full["options"]["insertSpaces"], false); + assert_eq!(full["options"]["trimFinalNewlines"], false); + assert_eq!(full["options"]["custom"], "x"); + assert_eq!(full["options"]["insertFinalNewline"], true); + let range = feature_request_params(&request( + "textDocument/rangeFormatting", + Some(options), + true, + )) + .unwrap(); + assert_eq!(range["range"]["start"], json!({"line": 1, "character": 2})); + assert_eq!(range["range"]["end"], json!({"line": 3, "character": 0})); + assert_eq!(range["options"]["tabSize"], 2); + assert!( + feature_request_params(&request("textDocument/rangeFormatting", None, false)).is_err() + ); + } + + #[test] + fn invalid_formatting_options_are_rejected() { + for options in [ + json!({"tabSize": 0}), + json!({"tabSize": "4"}), + json!({"insertSpaces": 1}), + json!({"nested": {}}), + json!([1]), + ] { + assert!(feature_request_params(&request( + "textDocument/formatting", + Some(options), + false + )) + .is_err()); + } + } + + #[test] + fn range_formatting_capability_and_result_are_normalized() { + let features = + feature_names_from_capabilities(&json!({"documentRangeFormattingProvider": true})); + assert!(features.contains(&"rangeFormatting".to_string())); + let result = lsp_feature_result_for_method( + Some("textDocument/rangeFormatting"), + Some(&json!([{ + "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 1}}, + "newText": "x" + }])), + ) + .unwrap(); + assert_eq!(result["edits"][0]["newText"], "x"); + } + + #[test] + fn completion_options_union_static_and_dynamic_providers() { + let mut state = LspClientState::default(); + assert!(completion_options(&state).is_none()); + state.server_capabilities.push("completion".into()); + state.completion_providers.insert( + String::new(), + json!({"triggerCharacters": [".", ":", ""], "allCommitCharacters": [";"]}), + ); + apply_dynamic_registration( + &mut state, + &json!({"params": {"registrations": [{"id": "c1", "method": "textDocument/completion", + "registerOptions": {"triggerCharacters": [":", ">"]}}]}}), + ); + assert_eq!( + completion_options(&state).unwrap(), + json!({"triggerCharacters": [".", ":", ">"], "allCommitCharacters": [";"]}) + ); + apply_dynamic_unregistration( + &mut state, + &json!({"params": {"unregisterations": [{"id": "c1", "method": "textDocument/completion"}]}}), + ); + // Withdrawing the completion registration also withdraws the feature. + assert!(completion_options(&state).is_none()); + assert!(!state.completion_providers.contains_key("c1")); + } + + #[test] + fn completion_result_preserves_is_incomplete() { + let result = lsp_feature_result_for_method( + Some("textDocument/completion"), + Some(&json!({"isIncomplete": true, "items": [{"label": "a"}]})), + ) + .unwrap(); + assert_eq!(result["isIncomplete"], true); + assert_eq!(result["items"][0]["label"], "a"); + } +} diff --git a/rust/lithe-core/src/lsp/interface/engine.rs b/rust/lithe-core/src/lsp/interface/engine.rs index f827c65a..4aa32a7c 100644 --- a/rust/lithe-core/src/lsp/interface/engine.rs +++ b/rust/lithe-core/src/lsp/interface/engine.rs @@ -257,6 +257,8 @@ pub enum LspSemanticOperation { Rename, /// `textDocument/formatting`. Formatting, + /// `textDocument/rangeFormatting` over the request's `range`. + RangeFormatting, /// `textDocument/codeAction`. CodeActions, /// `completionItem/resolve` for a previously returned completion item. @@ -302,6 +304,10 @@ pub struct SemanticRequest { pub code_action: Option, #[serde(default)] pub command: Option, + /// Optional LSP `FormattingOptions` (`tabSize`, `insertSpaces`, ...) for + /// `formatting` and `rangeFormatting`; unset fields keep Core defaults. + #[serde(default)] + pub formatting_options: Option, } #[derive(Debug, Clone, Deserialize)] @@ -607,6 +613,10 @@ pub struct LspRuntimeEvent { pub error: Option, #[serde(skip_serializing_if = "Option::is_none")] pub capabilities: Option>, + /// On `featuresChanged` while `completion` is offered: the session's + /// `{ triggerCharacters, allCommitCharacters }`, from `completionProvider`. + #[serde(skip_serializing_if = "Option::is_none")] + pub completion_options: Option, #[serde(skip_serializing_if = "Option::is_none")] pub server_info: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -2729,6 +2739,7 @@ impl RuntimeSession { completion_item: request.completion_item, code_action: request.code_action, command: request.command, + formatting_options: request.formatting_options, }; client_feature_request_canonical(feature_request)? } @@ -3123,7 +3134,7 @@ impl RuntimeSession { } else { None }; - let (known_pending, pending_before, old_capabilities) = { + let (known_pending, pending_before, old_capabilities, old_completion) = { let state = self.lock_state()?; let pending = response_id .as_ref() @@ -3134,6 +3145,7 @@ impl RuntimeSession { .is_none_or(|id| state.client.pending_requests.contains_key(id)), pending, state.client.server_capabilities.clone(), + super::client::completion_options(&state.client), ) }; // A response whose request has timed out, been cancelled, or belongs @@ -3563,7 +3575,8 @@ impl RuntimeSession { } } } - if state.client.server_capabilities != old_capabilities + if (state.client.server_capabilities != old_capabilities + || super::client::completion_options(&state.client) != old_completion) && state.lifecycle == LspLifecycleState::Ready { let capabilities = state.client.server_capabilities.clone(); @@ -4375,6 +4388,7 @@ fn semantic_method(operation: LspSemanticOperation) -> &'static str { LspSemanticOperation::Implementation => "textDocument/implementation", LspSemanticOperation::Rename => "textDocument/rename", LspSemanticOperation::Formatting => "textDocument/formatting", + LspSemanticOperation::RangeFormatting => "textDocument/rangeFormatting", LspSemanticOperation::CodeActions => "textDocument/codeAction", LspSemanticOperation::ResolveCompletion => "completionItem/resolve", LspSemanticOperation::ResolveCodeAction => "codeAction/resolve", @@ -4397,6 +4411,7 @@ fn semantic_capability(operation: LspSemanticOperation) -> Option<&'static str> LspSemanticOperation::Implementation => Some("implementation"), LspSemanticOperation::Rename => Some("rename"), LspSemanticOperation::Formatting => Some("formatting"), + LspSemanticOperation::RangeFormatting => Some("rangeFormatting"), LspSemanticOperation::CodeActions => Some("codeActions"), LspSemanticOperation::ResolveCompletion => Some("completionResolve"), LspSemanticOperation::ResolveCodeAction => Some("codeActionResolve"), @@ -4536,6 +4551,7 @@ fn push_cleanup_fact( result: None, error: Some(error), capabilities: None, + completion_options: None, server_info: None, level: Some("warning".to_string()), message: Some("Engine process cleanup failed".to_string()), @@ -4601,6 +4617,7 @@ fn transition_locked( result: None, error, capabilities: None, + completion_options: None, server_info: None, level: None, message: None, @@ -4704,6 +4721,7 @@ fn enqueue_request_completed( result, error, capabilities: None, + completion_options: None, server_info: None, level: None, message: None, @@ -4835,6 +4853,7 @@ fn push_build_progress_locked( result: Some(progress), error: None, capabilities: None, + completion_options: None, server_info: None, level: None, message: None, @@ -4893,6 +4912,7 @@ fn push_function_progress_locked( result: Some(progress), error: None, capabilities: None, + completion_options: None, server_info: None, level: None, message: None, @@ -4928,6 +4948,7 @@ fn push_vcs_provider_event(session: &RuntimeSession, state: &mut SessionState, r result: Some(result), error: None, capabilities: None, + completion_options: None, server_info: None, level: None, message: None, @@ -4967,6 +4988,7 @@ fn push_negotiated_provider_event( result: Some(result), error: None, capabilities: None, + completion_options: None, server_info: None, level: None, message: None, @@ -5038,6 +5060,7 @@ fn cancel_host_request_locked( result: Some(json!({ "hostRequestID": host_request_id })), error: None, capabilities: None, + completion_options: None, server_info: None, level: None, message: None, @@ -5105,6 +5128,7 @@ fn forward_host_request_locked( })), error: None, capabilities: None, + completion_options: None, server_info: None, level: None, message: None, @@ -5139,6 +5163,7 @@ fn push_functions_event(session: &RuntimeSession, state: &mut SessionState, resu result: Some(result), error: None, capabilities: None, + completion_options: None, server_info: None, level: None, message: None, @@ -5462,6 +5487,7 @@ fn push_diagnostics_event( result: None, error: None, capabilities: None, + completion_options: None, server_info: None, level: None, message: None, @@ -5495,6 +5521,7 @@ fn push_semantic_tokens_refresh_event(session: &RuntimeSession, state: &mut Sess result: None, error: None, capabilities: None, + completion_options: None, server_info: None, level: None, message: None, @@ -5508,6 +5535,7 @@ fn push_features_event( state: &mut SessionState, capabilities: Vec, ) { + let completion_options = super::client::completion_options(&state.client); let sequence = take_sequence(state); enqueue_runtime_event( session, @@ -5532,6 +5560,7 @@ fn push_features_event( result: None, error: None, capabilities: Some(capabilities), + completion_options, server_info: None, level: None, message: None, @@ -5565,6 +5594,7 @@ fn push_server_info_event(session: &RuntimeSession, state: &mut SessionState, in result: None, error: None, capabilities: None, + completion_options: None, server_info: Some(info), level: None, message: None, @@ -5714,6 +5744,7 @@ fn push_classified_log_event( result: None, error, capabilities: None, + completion_options: None, server_info: None, level: Some(level.to_string()), message: Some(message.to_string()), @@ -5856,6 +5887,7 @@ fn clear_runtime_state(session: &RuntimeSession, state: &mut SessionState) { state.client.text_document_save_include_text = None; state.client.shutdown_requested = false; state.client.server_capabilities.clear(); + state.client.completion_providers.clear(); state.client.open_documents.clear(); state.client.pending_requests.clear(); state.pending.clear(); @@ -6300,6 +6332,7 @@ mod tests { completion_item: None, code_action: None, command: None, + formatting_options: None, }, operation_id.clone(), ) @@ -8051,6 +8084,27 @@ mod tests { } /// Criterion 14: dynamic registration and unregistration change availability. + #[test] + fn features_event_reports_completion_trigger_characters() { + let mut harness = Harness::start(|_| {}); + harness.server.complete_initialize(json!({ + "completionProvider": { + "triggerCharacters": [".", ":", ">"], + "allCommitCharacters": [";"] + } + })); + let event = harness + .await_event(|event| { + event.kind == "featuresChanged" && event.completion_options.is_some() + }) + .clone(); + let json = serde_json::to_value(&event).unwrap(); + assert_eq!( + json["completionOptions"], + json!({"triggerCharacters": [".", ":", ">"], "allCommitCharacters": [";"]}) + ); + } + #[test] fn dynamic_capability_registration_and_unregistration_change_availability() { let mut harness = Harness::start(|_| {}); @@ -8078,6 +8132,7 @@ mod tests { completion_item: None, code_action: None, command: None, + formatting_options: None, }, "op-formatting".to_string(), ) @@ -8144,6 +8199,7 @@ mod tests { completion_item: None, code_action: None, command: None, + formatting_options: None, }, "op-formatting-2".to_string(), ) @@ -8277,6 +8333,7 @@ mod tests { "command": "source.fix", "arguments": [] })), + formatting_options: None, }, operation_id, ) diff --git a/rust/lithe-core/src/lsp/interface/types.rs b/rust/lithe-core/src/lsp/interface/types.rs index ea933764..8a94ac09 100644 --- a/rust/lithe-core/src/lsp/interface/types.rs +++ b/rust/lithe-core/src/lsp/interface/types.rs @@ -136,6 +136,11 @@ pub struct LspClientState { pub shutdown_requested: bool, #[serde(default)] pub server_capabilities: Vec, + /// `completionProvider` options by registration id: the static capability + /// under the empty id, dynamic registrations under their own ids. Only the + /// trigger and commit characters are read from them. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub completion_providers: BTreeMap, /// Full semantic-token registrations, including the server-owned legends. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub semantic_token_providers: BTreeMap, @@ -199,6 +204,7 @@ impl Default for LspClientState { initialized: false, shutdown_requested: false, server_capabilities: Vec::new(), + completion_providers: BTreeMap::new(), semantic_token_providers: BTreeMap::new(), text_document_sync: LspTextDocumentSyncKind::Full, text_document_save_include_text: None, @@ -349,6 +355,9 @@ pub struct ClientFeatureRequest { pub code_action: Option, #[serde(default)] pub command: Option, + /// Caller `FormattingOptions` for `formatting` and `rangeFormatting`. + #[serde(default)] + pub formatting_options: Option, } #[cfg(test)] diff --git a/rust/lithe-core/src/lsp/tests.rs b/rust/lithe-core/src/lsp/tests.rs index 8fc0eebc..22f99d37 100644 --- a/rust/lithe-core/src/lsp/tests.rs +++ b/rust/lithe-core/src/lsp/tests.rs @@ -773,6 +773,7 @@ fn client_core_tracks_documents_and_feature_requests() { completion_item: None, code_action: None, command: None, + formatting_options: None, }) .unwrap(); assert_eq!( @@ -1194,6 +1195,7 @@ fn client_core_preserves_class_completion_namespace_and_import_edits() { completion_item: None, code_action: None, command: None, + formatting_options: None, }) .unwrap(); let completed = client_apply_server_message(ClientApplyServerMessageRequest { @@ -1241,6 +1243,7 @@ fn client_core_preserves_class_completion_namespace_and_import_edits() { })), code_action: None, command: None, + formatting_options: None, }) .unwrap(); let resolved = client_apply_server_message(ClientApplyServerMessageRequest { @@ -1618,6 +1621,7 @@ fn client_core_shapes_feature_responses_for_swift_models() { completion_item: None, code_action: None, command: None, + formatting_options: None, }) .unwrap(); let completed = client_apply_server_message(ClientApplyServerMessageRequest { @@ -1669,6 +1673,7 @@ fn client_core_shapes_feature_responses_for_swift_models() { completion_item: None, code_action: None, command: None, + formatting_options: None, }) .unwrap(); let renamed = client_apply_server_message(ClientApplyServerMessageRequest { @@ -1709,6 +1714,7 @@ fn client_core_shapes_feature_responses_for_swift_models() { completion_item: None, code_action: None, command: None, + formatting_options: None, }) .unwrap(); let formatted = client_apply_server_message(ClientApplyServerMessageRequest { @@ -1786,6 +1792,7 @@ fn client_core_shapes_feature_responses_for_swift_models() { completion_item: None, code_action: None, command: None, + formatting_options: None, }) .unwrap(); let code_action_request: Value = serde_json::from_str(&code_actions.messages[0]).unwrap(); @@ -1867,6 +1874,7 @@ fn client_core_shapes_feature_responses_for_swift_models() { "data": { "id": "action-1" } })), command: None, + formatting_options: None, }) .unwrap(); let code_action_resolve_request: Value = @@ -1932,6 +1940,7 @@ fn client_core_shapes_feature_responses_for_swift_models() { })), code_action: None, command: None, + formatting_options: None, }) .unwrap(); let completion_resolve_request: Value = @@ -1983,6 +1992,7 @@ fn client_core_shapes_feature_responses_for_swift_models() { "command": "rust-analyzer.applySourceChange", "arguments": [{ "label": "rename" }] })), + formatting_options: None, }) .unwrap(); let execute_request: Value = serde_json::from_str(&execute_command.messages[0]).unwrap(); @@ -2036,6 +2046,7 @@ fn client_core_requests_and_shapes_inlay_hints_folding_ranges_and_code_lenses() completion_item: None, code_action: None, command: None, + formatting_options: None, }) .unwrap(); let request_json: Value = serde_json::from_str(&inlay_request.messages[0]).unwrap(); @@ -2088,6 +2099,7 @@ fn client_core_requests_and_shapes_inlay_hints_folding_ranges_and_code_lenses() completion_item: None, code_action: None, command: None, + formatting_options: None, }) .unwrap(); let request_json: Value = serde_json::from_str(&folding_request.messages[0]).unwrap(); @@ -2130,6 +2142,7 @@ fn client_core_requests_and_shapes_inlay_hints_folding_ranges_and_code_lenses() completion_item: None, code_action: None, command: None, + formatting_options: None, }) .unwrap(); let code_lens_response = client_apply_server_message(ClientApplyServerMessageRequest { @@ -2185,6 +2198,7 @@ fn navigation_locations_preserve_uris_without_fabricating_virtual_file_paths() { completion_item: None, code_action: None, command: None, + formatting_options: None, }) .unwrap(); let response = client_apply_server_message(ClientApplyServerMessageRequest { diff --git a/rust/lithe-core/src/protocol/error.rs b/rust/lithe-core/src/protocol/error.rs index a6eea351..ad0f08d1 100644 --- a/rust/lithe-core/src/protocol/error.rs +++ b/rust/lithe-core/src/protocol/error.rs @@ -27,6 +27,9 @@ pub enum ErrorCode { GitBranchExists, /// The reviewed checkout or its GitHub origin no longer matches the current repository. GitCheckoutChanged, + /// The Git remote refused the credentials, or none were available. Only + /// `git.write` reports it, as `operationError.code` of a failed response. + GitAuthenticationFailed, /// Input or tool output could not be decoded into the stable contract. ParseFailed, /// The caller cooperatively cancelled the operation. diff --git a/shared/contracts/plugins/v1/README.md b/shared/contracts/plugins/v1/README.md index cc525136..2fc798f6 100644 --- a/shared/contracts/plugins/v1/README.md +++ b/shared/contracts/plugins/v1/README.md @@ -463,6 +463,7 @@ process does not use `${workspaceStateDir}`; otherwise the request is | Field | Meaning | | --- | --- | +| `owner` | `{pluginID, version}` of the installed package Core validated for this plan. The host starts the server for that exact installation and uses this identity, not one it read elsewhere, when it later relays requests or reports the session. | | `launch` | `{kind: "packaged", path}`, `{kind: "systemCommand", commands}`, or `{kind: "runtime", tool, entry, packagedRuntime?, minimumVersion?, versionArguments?}`: what the host resolves to an executable. `packagedRuntime` is the expanded package-relative path of a shipped runtime. `minimumVersion` and `versionArguments` appear together when a discovered runtime must meet a minimum version. A `runtime` launch whose `tool` names a tool definition, by `serverProcess` 4 or implicitly, returns the `{kind: "tool", …}` shape of [Tool references](#tool-references-serverprocess-4) instead. A [backend-resolved launch](#backend-resolved-launch-serverprocess-3) returns `{kind: "backend", function, timeoutMilliseconds}` here, and `{kind: "tool", tool}` appears in the plan `plugins.languageServers.decodeLaunch` returns. | | `arguments` | Complete argument list after the executable, including the entry of a `node` runtime launch. A tool-shaped launch returns only the plugin's own arguments. | | `validationArguments` | Expanded probe arguments. | diff --git a/shared/contracts/plugins/v1/fixtures/languages/command-plans.json b/shared/contracts/plugins/v1/fixtures/languages/command-plans.json index c64a5a89..9200afed 100644 --- a/shared/contracts/plugins/v1/fixtures/languages/command-plans.json +++ b/shared/contracts/plugins/v1/fixtures/languages/command-plans.json @@ -207,6 +207,7 @@ "expected": { "status": "ok", "plan": { + "owner": { "pluginID": "dev.lithe.fixture.node-runtime", "version": "1.0.0" }, "launch": { "kind": "runtime", "tool": "node", @@ -276,6 +277,7 @@ "expected": { "status": "ok", "plan": { + "owner": { "pluginID": "dev.lithe.fixture.node-runtime", "version": "1.0.0" }, "launch": { "kind": "runtime", "tool": "node", @@ -346,6 +348,7 @@ "expected": { "status": "ok", "plan": { + "owner": { "pluginID": "dev.lithe.fixture.node-runtime", "version": "1.0.0" }, "launch": { "kind": "runtime", "tool": "node", @@ -413,6 +416,7 @@ "expected": { "status": "ok", "plan": { + "owner": { "pluginID": "dev.lithe.fixture.node-runtime", "version": "1.0.0" }, "launch": { "kind": "runtime", "tool": "node", @@ -477,6 +481,7 @@ "expected": { "status": "ok", "plan": { + "owner": { "pluginID": "dev.lithe.fixture.language-packaged-server", "version": "0.1.0" }, "launch": { "kind": "packaged", "path": "language-server/bin/intelephense" @@ -511,6 +516,7 @@ "expected": { "status": "ok", "plan": { + "owner": { "pluginID": "dev.lithe.fixture.language-system-command", "version": "0.1.0" }, "launch": { "kind": "systemCommand", "commands": [ @@ -545,6 +551,7 @@ "expected": { "status": "ok", "plan": { + "owner": { "pluginID": "dev.lithe.fixture.build-provider-language-server", "version": "0.1.0" }, "launch": { "kind": "systemCommand", "commands": [ @@ -581,6 +588,7 @@ "expected": { "status": "ok", "plan": { + "owner": { "pluginID": "dev.lithe.fixture.build-provider-language-server", "version": "0.1.0" }, "launch": { "kind": "systemCommand", "commands": [ @@ -647,6 +655,7 @@ "expected": { "status": "ok", "plan": { + "owner": { "pluginID": "dev.lithe.fixture.node-runtime", "version": "1.0.0" }, "launch": { "kind": "runtime", "tool": "node", diff --git a/shared/contracts/plugins/v1/fixtures/languages/language-server-launch-resolution.json b/shared/contracts/plugins/v1/fixtures/languages/language-server-launch-resolution.json index 97637f21..14a58898 100644 --- a/shared/contracts/plugins/v1/fixtures/languages/language-server-launch-resolution.json +++ b/shared/contracts/plugins/v1/fixtures/languages/language-server-launch-resolution.json @@ -18,6 +18,7 @@ "expected": { "status": "ok", "plan": { + "owner": { "pluginID": "dev.lithe.fixture.language-server-backend-launch", "version": "0.1.0" }, "launch": { "kind": "backend", "function": "metals.resolveLaunch", diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 3bec93fd..997cd927 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -1971,6 +1971,18 @@ Fetch's existing explicit flags remain in the argument vector for compatibility. `LC_ALL=C`, pager suppression and no terminal prompting are child-local policy. Transfer progress is requested explicitly. No config file is implicitly modified. +Because every Git child runs with `LC_ALL=C` and no terminal prompting, its +authentication diagnostics are fixed English text whatever the system language. +A failed `git.write` response (non-zero `exitCode`, or an `operationError` with +code `process_failed`) whose Git stderr shows the remote refused or lacked +credentials (`Authentication failed`, `could not read Username/Password`, +`Permission denied (publickey…)`, HTTP 401/403, GitHub `Permission to … denied +to …`) reports `operationError.code = "git_authentication_failed"`. When Core +adds that error to a response that had none, `message` is the first stderr line +and `details` the combined output; an existing message and details are kept. +Other failures keep their codes, so hosts must not match stderr themselves. +Samples: `shared/fixtures/git/authentication-failure-v1.json`. + `git.remoteUrl` accepts `{ root, remote }` and returns `{ url: string | null }`. It uses the internal configuration-read path: a missing key is successful and produces no command row. Invalid requests and real access/executable failures @@ -4037,7 +4049,7 @@ explicit `plugin.function.*` error. the operation-specific URI, position, range, diagnostics, item, action, or command fields, and returns `{ operationId }`. Supported operations include completion and resolve, hover, definition/declaration/type-definition, references, -implementation, rename, formatting, code actions and resolve, execute command, +implementation, rename, formatting, range formatting, code actions and resolve, execute command, inlay hints, full-document semantic tokens, folding ranges, code lens, provider virtual documents, `javaEntrypoints`, `javaTestItems`, and `javaMainMethods`. For code actions, `diagnostics` contains the original `LspClientDiagnostic` @@ -5962,6 +5974,26 @@ Missing or stale HEAD rejects the amendment before writing; plain Continue, Skip and Abort do not require this field. Both products must send the HEAD reviewed by the amendment editor. See [Rebase sessions](git-rebase-session.md). +Operation `rangeFormatting` (`textDocument/rangeFormatting`) formats the +required `range` and is offered when the server declares +`documentRangeFormattingProvider`, as the `rangeFormatting` feature. Its result, +like `formatting`, is `{ edits }`. Both formatting operations accept optional +`formattingOptions`, an object of LSP `FormattingOptions`: `tabSize` (integer +1-1024), `insertSpaces`, `trimTrailingWhitespace`, `insertFinalNewline`, +`trimFinalNewlines` (booleans), and other keys whose values are boolean, +integer or string. Supplied values are merged over the defaults `tabSize: 4`, +`insertSpaces: true`, `trimTrailingWhitespace: true`, `insertFinalNewline: true`, +`trimFinalNewlines: true`, so omitting the field keeps the previous behaviour. +Invalid values fail with `invalid_request`. + +The `featuresChanged` event carries `completionOptions: +{ triggerCharacters: string[], allCommitCharacters: string[] }` whenever the +`completion` feature is offered: the de-duplicated union of the server's +`completionProvider` and its dynamic `textDocument/completion` registrations +(empty arrays when none are declared). The event is re-sent when registrations +change them. Hosts use it, not a hard-coded list, to send +`completionContext.triggerKind = 2`. + Completion requests accept optional `completionContext` with LSP `triggerKind` (`1` invoked, `2` trigger character, `3` incomplete-list refresh) and `triggerCharacter` (required and one character for kind `2`). Missing context diff --git a/shared/fixtures/git/authentication-failure-v1.json b/shared/fixtures/git/authentication-failure-v1.json new file mode 100644 index 00000000..3c095acc --- /dev/null +++ b/shared/fixtures/git/authentication-failure-v1.json @@ -0,0 +1,15 @@ +{ + "errorCode": "git_authentication_failed", + "failures": [ + {"name": "http-bad-credentials", "stderr": "remote: Invalid username or password.\nfatal: Authentication failed for 'https://example.com/a.git/'\n"}, + {"name": "http-no-credential", "stderr": "fatal: could not read Username for 'https://example.com': terminal prompts disabled\n"}, + {"name": "http-forbidden", "stderr": "remote: Permission to a/b.git denied to someone.\nfatal: unable to access 'https://github.com/a/b.git/': The requested URL returned error: 403\n"}, + {"name": "ssh-publickey", "stderr": "git@github.com: Permission denied (publickey).\nfatal: Could not read from remote repository.\n\nPlease make sure you have the correct access rights\nand the repository exists.\n"} + ], + "unrelated": [ + {"name": "dns", "stderr": "fatal: unable to access 'https://example.com/': Could not resolve host: example.com\n"}, + {"name": "not-found", "stderr": "fatal: repository 'https://example.com/a.git/' not found\n"}, + {"name": "ssh-timeout", "stderr": "ssh: connect to host example.com port 22: Connection timed out\nfatal: Could not read from remote repository.\n"}, + {"name": "non-fast-forward", "stderr": "! [rejected] main -> main (non-fast-forward)\n"} + ] +}