diff --git a/docs/reference/CLI_REFERENCE.md b/docs/reference/CLI_REFERENCE.md index 579d14f..b324f58 100644 --- a/docs/reference/CLI_REFERENCE.md +++ b/docs/reference/CLI_REFERENCE.md @@ -77,6 +77,25 @@ Notes: - `--allow-shell` enables shell tool use broadly, subject to the trust gate. - `--allow-shell-in-workdir` is narrower: it allows shell only when cwd is omitted or remains under the current workdir. +### LSP Tools + +- `--lsp-provider ` +- `--lsp-command ` + +When `--lsp-provider` is set, LocalAgent exposes read-only built-in tools: + +- `lsp.diagnostics` +- `lsp.document_symbols` +- `lsp.goto_definition` +- `lsp.find_references` +- `lsp.hover` + +These tools are workdir-scoped, use the configured language server command, and are classified as filesystem-read tools for gate/audit purposes. + +* `Evidence: src/cli_args.rs#LspProviderKind` +* `Evidence: src/tools/catalog.rs#builtin_tools_enabled_with_lsp` +* `Evidence: src/tools/exec_lsp.rs` + ### Execution Target - `--exec-target ` (default: `host`) diff --git a/src/agent_runtime.rs b/src/agent_runtime.rs index 6fabd01..79ec761 100644 --- a/src/agent_runtime.rs +++ b/src/agent_runtime.rs @@ -310,6 +310,19 @@ pub(crate) async fn run_agent_with_ui( }, unsafe_bypass_allow_flags: args.unsafe_bypass_allow_flags, tool_args_strict: resolved_settings.tool_args_strict, + lsp: args + .lsp_provider + .map(|provider| crate::tools::LspToolRuntime { + provider: match provider { + crate::cli_args::LspProviderKind::Rust => { + crate::tools::LspToolProvider::Rust + } + crate::cli_args::LspProviderKind::Typescript => { + crate::tools::LspToolProvider::Typescript + } + }, + command: args.lsp_command.clone(), + }), exec_target_kind: resolved_target_kind, exec_target, }, diff --git a/src/agent_tests.rs b/src/agent_tests.rs index b70b8b4..a5c3091 100644 --- a/src/agent_tests.rs +++ b/src/agent_tests.rs @@ -137,6 +137,7 @@ fn edit_workflow_system_prompt_matches_write_file_contract() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }, @@ -283,6 +284,7 @@ async fn compaction_failure_emits_run_end_provider_error() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }, @@ -406,6 +408,7 @@ async fn non_stream_mode_uses_non_stream_generate() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }, @@ -503,6 +506,7 @@ async fn task_memory_message_is_injected_into_transcript() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }, @@ -614,6 +618,7 @@ async fn build_initial_messages_contains_tool_contract_version_marker() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }, @@ -3324,6 +3329,7 @@ async fn emits_tool_exec_target_before_exec_start() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }, @@ -3433,6 +3439,7 @@ async fn plan_tool_enforcement_hard_denies_disallowed_tool() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }, @@ -3544,6 +3551,7 @@ async fn operator_interrupt_delivers_post_tool_and_cancels_remaining_turn_work() max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }, @@ -3662,6 +3670,7 @@ async fn operator_next_delivers_at_turn_idle_without_interrupt() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }, @@ -3776,6 +3785,7 @@ async fn halting_is_blocked_when_plan_steps_are_pending() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }, @@ -3876,6 +3886,7 @@ async fn emits_step_lifecycle_events_for_pending_plan_halt() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }, @@ -3986,6 +3997,7 @@ async fn tool_budget_exceeded_returns_deterministic_exit() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }, @@ -4091,6 +4103,7 @@ async fn multiple_tool_calls_in_single_step_fail_with_protocol_violation() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }, @@ -4193,6 +4206,7 @@ async fn planner_enforced_final_output_uses_user_output_field() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }, @@ -4304,6 +4318,7 @@ async fn schema_repair_retry_happens_before_execution() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }, @@ -4425,6 +4440,7 @@ async fn repeated_malformed_tool_calls_fail_fast_with_protocol_violation() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }, @@ -4562,6 +4578,7 @@ async fn edit_aliases_do_not_trip_malformed_tool_guard() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }, @@ -4670,6 +4687,7 @@ async fn repeated_failed_unknown_tool_calls_are_blocked_by_repeat_guard() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }, @@ -4785,6 +4803,7 @@ async fn repeated_invalid_patch_format_fails_fast_with_protocol_violation() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }, @@ -4928,6 +4947,7 @@ async fn runtime_post_write_verification_allows_finalize_without_model_read_back max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }, @@ -5099,6 +5119,7 @@ async fn runtime_post_write_missing_closeout_gets_one_bounded_final_answer_turn( max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }, @@ -5275,6 +5296,7 @@ async fn runtime_pre_tool_plan_text_does_not_count_as_post_tool_closeout() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }, @@ -5432,6 +5454,7 @@ async fn post_write_known_validation_goes_directly_to_validation_only_phase() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(ShellSuccessExecTarget::default()), }, @@ -5567,6 +5590,7 @@ async fn echoed_tool_result_wrapper_is_blocked_before_finalization() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }, @@ -5696,6 +5720,7 @@ async fn fabricated_tool_result_after_read_gets_path_aware_write_recovery() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }, @@ -5829,6 +5854,7 @@ async fn echoed_box_wrapper_is_blocked_before_finalization() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }, @@ -5957,6 +5983,7 @@ async fn runtime_noop_apply_patch_does_not_finalize_ok() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }, @@ -6105,6 +6132,7 @@ async fn runtime_exact_final_answer_retry_allows_one_bounded_retry() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }, @@ -6249,6 +6277,7 @@ async fn runtime_exact_final_answer_retry_classifies_noncompliant_output() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }, @@ -6392,6 +6421,7 @@ async fn runtime_exact_final_answer_requires_successful_validation_command() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }, @@ -6546,6 +6576,7 @@ async fn runtime_required_validation_guard_allows_one_bounded_shell_retry() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(ShellSuccessExecTarget::default()), }, @@ -6705,6 +6736,7 @@ async fn runtime_required_validation_phase_direct_handoff_repairs_empty_turn() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(ShellSuccessExecTarget::default()), }, @@ -6871,6 +6903,7 @@ async fn runtime_required_validation_phase_repairs_exact_final_answer_into_shell max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(ShellSuccessExecTarget::default()), }, @@ -7032,6 +7065,7 @@ async fn runtime_required_validation_phase_repairs_prose_only_into_shell() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(ShellSuccessExecTarget::default()), }, @@ -7191,6 +7225,7 @@ async fn runtime_required_validation_phase_repairs_wrong_tool_into_shell() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(ShellSuccessExecTarget::default()), }, @@ -7360,6 +7395,7 @@ async fn runtime_post_validation_phase_blocks_tool_drift_and_recovers_to_final_a max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(ShellSuccessExecTarget::default()), }, @@ -7529,6 +7565,7 @@ async fn runtime_failed_validation_blocks_shell_retry_until_code_changes() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(FailThenSucceedShellExecTarget::default()), }, @@ -7686,6 +7723,7 @@ async fn runtime_exact_final_answer_allows_matching_successful_validation_comman max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(ShellSuccessExecTarget::default()), }, @@ -7835,6 +7873,7 @@ async fn runtime_exact_final_answer_recovers_wrapped_exact_block_after_validatio max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(ShellSuccessExecTarget::default()), }, @@ -7973,6 +8012,7 @@ async fn runtime_read_then_done_recovers_with_corrective_write_instruction() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }, @@ -8132,6 +8172,7 @@ async fn runtime_post_write_guard_retry_is_machine_classified() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }, @@ -8312,6 +8353,7 @@ async fn runtime_post_write_verification_timeout_fails_deterministically() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(SlowReadExecTarget { host: HostTarget, @@ -8474,6 +8516,7 @@ async fn runtime_tool_execution_timeout_is_bounded() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(SlowReadExecTarget { host: HostTarget, @@ -8598,6 +8641,7 @@ async fn invalid_patch_format_attempts_are_scoped_per_tool_key() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }, @@ -8715,6 +8759,7 @@ async fn tool_only_prompt_repairs_once_then_allows_tool_call() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }, @@ -8858,6 +8903,7 @@ async fn repeated_failed_str_replace_forces_pivot_before_repeat_block() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }, @@ -9026,6 +9072,7 @@ async fn repeated_failed_apply_patch_forces_smaller_fix_pivot_before_repeat_bloc max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }, @@ -9158,6 +9205,7 @@ async fn tool_only_prompt_repeated_prose_fails_fast() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }, @@ -9265,6 +9313,7 @@ async fn invalid_done_transition_fails_with_planner_error() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }, diff --git a/src/bin/lsp_stub.rs b/src/bin/lsp_stub.rs index 342f3a4..84dd9f5 100644 --- a/src/bin/lsp_stub.rs +++ b/src/bin/lsp_stub.rs @@ -118,6 +118,21 @@ fn main() -> Result<()> { }), )?; } + Some("textDocument/hover") => { + write_lsp_message( + &mut writer, + &json!({ + "jsonrpc": "2.0", + "id": message.get("id").cloned().unwrap_or(Value::Null), + "result": { + "contents": { + "kind": "markdown", + "value": "const value: number" + } + } + }), + )?; + } Some("shutdown") => { write_lsp_message( &mut writer, diff --git a/src/cli_args.rs b/src/cli_args.rs index 3640b62..46c32ef 100644 --- a/src/cli_args.rs +++ b/src/cli_args.rs @@ -1350,6 +1350,8 @@ pub(crate) enum RunOutputMode { #[derive(Debug, Clone, Copy, ValueEnum)] pub(crate) enum LspProviderKind { + Rust, + Typescript, } diff --git a/src/eval/runner_runtime.rs b/src/eval/runner_runtime.rs index 7a2c866..212582d 100644 --- a/src/eval/runner_runtime.rs +++ b/src/eval/runner_runtime.rs @@ -467,6 +467,7 @@ pub(crate) async fn run_single( max_read_bytes: if config.no_limits { 0 } else { 200_000 }, unsafe_bypass_allow_flags: config.unsafe_bypass_allow_flags, tool_args_strict: config.tool_args_strict, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }, diff --git a/src/lsp_context_provider.rs b/src/lsp_context_provider.rs index f043e30..bc77c62 100644 --- a/src/lsp_context_provider.rs +++ b/src/lsp_context_provider.rs @@ -11,6 +11,7 @@ pub(crate) fn resolve_default_lsp_context( ) -> Result> { match args.lsp_provider { None => Ok(None), + Some(LspProviderKind::Rust) => Ok(None), Some(LspProviderKind::Typescript) => { let provider = TypescriptLspContextProvider::new(args.lsp_command.clone()); match resolve_lsp_context(&args.workdir, &provider, limits) { diff --git a/src/repro.rs b/src/repro.rs index 315f43c..7a6362e 100644 --- a/src/repro.rs +++ b/src/repro.rs @@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::store::{config_hash_hex, sha256_hex, stable_path_string, RunRecord}; -use crate::tools::builtin_tools_enabled; +use crate::tools::builtin_tools_enabled_with_lsp; #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Serialize, Deserialize)] pub enum ReproMode { @@ -327,7 +327,7 @@ pub fn verify_run_record(record: &RunRecord, strict: bool) -> anyhow::Result( mcp_registry, policy_for_exposure, } = input; - let mut all_tools = builtin_tools_enabled( + let mut all_tools = builtin_tools_enabled_with_lsp( args.enable_write_tools, args.allow_shell || args.allow_shell_in_workdir, + args.lsp_provider.is_some(), ); let mut mcp_tool_snapshot: Vec = Vec::new(); if let Some(reg) = mcp_registry { diff --git a/src/tools.rs b/src/tools.rs index 6709a0f..f60e712 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -11,6 +11,7 @@ use crate::types::{Message, SideEffects, ToolCall}; mod catalog; mod envelope; mod exec_fs; +mod exec_lsp; mod exec_plan; mod exec_shell; mod exec_support; @@ -19,7 +20,7 @@ mod observation; mod schema; pub(crate) use catalog::normalize_builtin_tool_args; -pub use catalog::{builtin_tools_enabled, tool_side_effects}; +pub use catalog::{builtin_tools_enabled, builtin_tools_enabled_with_lsp, tool_side_effects}; pub use envelope::{ envelope_to_message, invalid_args_tool_message, to_tool_result_envelope, to_tool_result_envelope_with_error, @@ -53,6 +54,18 @@ impl ToolArgsStrict { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LspToolProvider { + Rust, + Typescript, +} + +#[derive(Debug, Clone)] +pub struct LspToolRuntime { + pub provider: LspToolProvider, + pub command: Option, +} + #[derive(Clone)] pub struct ToolRuntime { pub workdir: PathBuf, @@ -63,6 +76,7 @@ pub struct ToolRuntime { pub max_read_bytes: usize, pub unsafe_bypass_allow_flags: bool, pub tool_args_strict: ToolArgsStrict, + pub lsp: Option, pub exec_target_kind: ExecTargetKind, pub exec_target: Arc, } @@ -144,6 +158,8 @@ pub enum ToolErrorCode { ShellExecNonZeroExit, ShellExecTimeout, ShellExecTimeoutUnsupported, + LspUnavailable, + LspRequestFailed, } impl ToolErrorCode { @@ -164,6 +180,8 @@ impl ToolErrorCode { Self::ShellExecNonZeroExit => "shell_exec_non_zero_exit", Self::ShellExecTimeout => "shell_exec_timeout", Self::ShellExecTimeoutUnsupported => "shell_exec_timeout_unsupported", + Self::LspUnavailable => "lsp_unavailable", + Self::LspRequestFailed => "lsp_request_failed", } } } @@ -216,6 +234,11 @@ pub async fn execute_tool_streaming( "read_file" => exec_fs::run_read_file(rt, &normalized_args).await, "glob" => exec_fs::run_glob(rt, &normalized_args).await, "grep" => exec_fs::run_grep(rt, &normalized_args).await, + "lsp.diagnostics" + | "lsp.document_symbols" + | "lsp.goto_definition" + | "lsp.find_references" + | "lsp.hover" => exec_lsp::run_lsp_tool(rt, &tc.name, &normalized_args).await, "update_plan" => exec_plan::run_update_plan(rt, &normalized_args).await, "shell" => exec_shell::run_shell(rt, &normalized_args, shell_stream).await, "write_file" => exec_write::run_write_file(rt, &normalized_args).await, diff --git a/src/tools/catalog.rs b/src/tools/catalog.rs index 612a021..9d842f6 100644 --- a/src/tools/catalog.rs +++ b/src/tools/catalog.rs @@ -4,7 +4,15 @@ use crate::types::{SideEffects, ToolDef}; pub fn tool_side_effects(tool_name: &str) -> SideEffects { match tool_name { - "list_dir" | "read_file" | "glob" | "grep" => SideEffects::FilesystemRead, + "list_dir" + | "read_file" + | "glob" + | "grep" + | "lsp.diagnostics" + | "lsp.document_symbols" + | "lsp.goto_definition" + | "lsp.find_references" + | "lsp.hover" => SideEffects::FilesystemRead, "update_plan" => SideEffects::None, "shell" => SideEffects::ShellExec, "write_file" | "apply_patch" | "edit" | "str_replace" => SideEffects::FilesystemWrite, @@ -15,6 +23,14 @@ pub fn tool_side_effects(tool_name: &str) -> SideEffects { } pub fn builtin_tools_enabled(enable_write_tools: bool, enable_shell_tool: bool) -> Vec { + builtin_tools_enabled_with_lsp(enable_write_tools, enable_shell_tool, false) +} + +pub fn builtin_tools_enabled_with_lsp( + enable_write_tools: bool, + enable_shell_tool: bool, + enable_lsp_tools: bool, +) -> Vec { let mut tools = vec![ ToolDef { name: "list_dir".to_string(), @@ -91,6 +107,78 @@ pub fn builtin_tools_enabled(enable_write_tools: bool, enable_shell_tool: bool) side_effects: SideEffects::None, }, ]; + if enable_lsp_tools { + tools.push(ToolDef { + name: "lsp.diagnostics".to_string(), + description: "Read current language-server diagnostics for the workspace or a workdir-relative source file. Read-only.".to_string(), + parameters: json!({ + "type":"object", + "properties":{ + "path":{"type":"string"}, + "max_results":{"type":"integer","minimum":1,"maximum":1000} + } + }), + side_effects: SideEffects::FilesystemRead, + }); + tools.push(ToolDef { + name: "lsp.document_symbols".to_string(), + description: "Read document symbols for a workdir-relative Rust or TypeScript-family source file. Read-only.".to_string(), + parameters: json!({ + "type":"object", + "properties":{ + "path":{"type":"string"}, + "max_results":{"type":"integer","minimum":1,"maximum":1000} + }, + "required":["path"] + }), + side_effects: SideEffects::FilesystemRead, + }); + tools.push(ToolDef { + name: "lsp.goto_definition".to_string(), + description: "Read definition locations for a symbol position in a workdir-relative source file. Lines and columns are 1-based. Read-only.".to_string(), + parameters: json!({ + "type":"object", + "properties":{ + "path":{"type":"string"}, + "line":{"type":"integer","minimum":1}, + "column":{"type":"integer","minimum":1}, + "max_results":{"type":"integer","minimum":1,"maximum":1000} + }, + "required":["path","line","column"] + }), + side_effects: SideEffects::FilesystemRead, + }); + tools.push(ToolDef { + name: "lsp.find_references".to_string(), + description: "Read reference locations for a symbol position in a workdir-relative source file. Lines and columns are 1-based. Read-only.".to_string(), + parameters: json!({ + "type":"object", + "properties":{ + "path":{"type":"string"}, + "line":{"type":"integer","minimum":1}, + "column":{"type":"integer","minimum":1}, + "include_declaration":{"type":"boolean"}, + "max_results":{"type":"integer","minimum":1,"maximum":1000} + }, + "required":["path","line","column"] + }), + side_effects: SideEffects::FilesystemRead, + }); + tools.push(ToolDef { + name: "lsp.hover".to_string(), + description: "Read hover text for a symbol position in a workdir-relative source file. Lines and columns are 1-based. Read-only.".to_string(), + parameters: json!({ + "type":"object", + "properties":{ + "path":{"type":"string"}, + "line":{"type":"integer","minimum":1}, + "column":{"type":"integer","minimum":1} + }, + "required":["path","line","column"] + }), + side_effects: SideEffects::FilesystemRead, + }); + } if enable_shell_tool { tools.push(ToolDef { name: "shell".to_string(), diff --git a/src/tools/exec_lsp.rs b/src/tools/exec_lsp.rs new file mode 100644 index 0000000..3f210ee --- /dev/null +++ b/src/tools/exec_lsp.rs @@ -0,0 +1,917 @@ +use std::fs; +use std::io::{BufRead, BufReader, Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::mpsc; +use std::thread; +use std::time::Duration; + +use anyhow::{anyhow, Context, Result}; +use serde::Deserialize; +use serde_json::{json, Value}; + +use crate::diagnostics::{Diagnostic, Severity, DIAGNOSTIC_SCHEMA_VERSION}; +use crate::lsp_context::SymbolLocation; +use crate::types::SideEffects; + +use super::exec_support::{base_meta, failed_exec, path_is_workdir_scoped, ToolExecution}; +use super::{LspToolProvider, ToolErrorCode, ToolErrorDetail, ToolRuntime}; + +const DEFAULT_TYPESCRIPT_LSP_COMMAND: &str = "typescript-language-server"; +const DEFAULT_RUST_LSP_COMMAND: &str = "rust-analyzer"; +const INITIALIZE_TIMEOUT_MS: u64 = 2_000; +const RESPONSE_TIMEOUT_MS: u64 = 2_000; +const DIAGNOSTICS_TOTAL_TIMEOUT_MS: u64 = 3_000; +const DIAGNOSTICS_IDLE_AFTER_FIRST_MS: u64 = 500; +const MAX_OPEN_FILES: usize = 12; + +pub(super) async fn run_lsp_tool(rt: &ToolRuntime, tool_name: &str, args: &Value) -> ToolExecution { + let Some(config) = rt.lsp.clone() else { + return failed_exec( + rt, + SideEffects::FilesystemRead, + "LSP tools are disabled. Re-run with --lsp-provider rust or --lsp-provider typescript." + .to_string(), + Some(ToolErrorDetail { + code: ToolErrorCode::ToolDisabled, + message: "LSP tools are disabled for this run.".to_string(), + expected_schema: super::compact_builtin_schema(tool_name), + received_args: Some(args.clone()), + minimal_example: super::minimal_builtin_example(tool_name), + available_tools: None, + }), + ); + }; + + match run_lsp_tool_inner(rt, &config, tool_name, args) { + Ok(content) => ok_json(rt, content), + Err(err) => failed_exec( + rt, + SideEffects::FilesystemRead, + format!("lsp request failed: {err}"), + Some(ToolErrorDetail { + code: ToolErrorCode::LspRequestFailed, + message: err.to_string(), + expected_schema: super::compact_builtin_schema(tool_name), + received_args: Some(args.clone()), + minimal_example: super::minimal_builtin_example(tool_name), + available_tools: None, + }), + ), + } +} + +fn run_lsp_tool_inner( + rt: &ToolRuntime, + config: &super::LspToolRuntime, + tool_name: &str, + args: &Value, +) -> Result { + let mut session = LspSession::start(config, &rt.workdir)?; + match tool_name { + "lsp.diagnostics" => { + let files = if let Some(path) = args.get("path").and_then(|v| v.as_str()) { + vec![scoped_source_file(rt, config.provider, path)?] + } else { + discover_source_files(&rt.workdir, config.provider, MAX_OPEN_FILES)? + }; + for file in &files { + session.open_document(file, config.provider)?; + } + let diagnostics = session.collect_diagnostics()?; + let max_results = max_results(args, 100); + let diagnostic_count = diagnostics.len(); + let truncated = diagnostic_count > max_results; + Ok(json!({ + "provider": provider_name(config.provider), + "diagnostics": truncate_values(diagnostics, max_results), + "diagnostic_count": diagnostic_count, + "truncated": truncated, + "max_results": max_results + })) + } + "lsp.document_symbols" => { + let path = required_path(args)?; + let file = scoped_source_file(rt, config.provider, path)?; + session.open_document(&file, config.provider)?; + let symbols = session.document_symbols(&file)?; + let max_results = max_results(args, 100); + let symbol_count = symbols.len(); + let truncated = symbol_count > max_results; + Ok(json!({ + "provider": provider_name(config.provider), + "path": path, + "symbols": truncate_values(symbols, max_results), + "symbol_count": symbol_count, + "truncated": truncated, + "max_results": max_results + })) + } + "lsp.goto_definition" => { + let path = required_path(args)?; + let file = scoped_source_file(rt, config.provider, path)?; + session.open_document(&file, config.provider)?; + let locations = session.location_request( + "textDocument/definition", + &file, + required_zero_based_position(args)?, + None, + )?; + let max_results = max_results(args, 20); + let definition_count = locations.len(); + let truncated = definition_count > max_results; + Ok(json!({ + "provider": provider_name(config.provider), + "path": path, + "definitions": truncate_values(locations, max_results), + "definition_count": definition_count, + "truncated": truncated, + "max_results": max_results + })) + } + "lsp.find_references" => { + let path = required_path(args)?; + let file = scoped_source_file(rt, config.provider, path)?; + session.open_document(&file, config.provider)?; + let locations = session.location_request( + "textDocument/references", + &file, + required_zero_based_position(args)?, + Some(json!({ + "context": { + "includeDeclaration": args + .get("include_declaration") + .and_then(|v| v.as_bool()) + .unwrap_or(true) + } + })), + )?; + let max_results = max_results(args, 100); + let reference_count = locations.len(); + let truncated = reference_count > max_results; + Ok(json!({ + "provider": provider_name(config.provider), + "path": path, + "references": truncate_values(locations, max_results), + "reference_count": reference_count, + "truncated": truncated, + "max_results": max_results + })) + } + "lsp.hover" => { + let path = required_path(args)?; + let file = scoped_source_file(rt, config.provider, path)?; + session.open_document(&file, config.provider)?; + let hover = session.hover(&file, required_zero_based_position(args)?)?; + Ok(json!({ + "provider": provider_name(config.provider), + "path": path, + "hover": hover + })) + } + _ => Err(anyhow!("unknown lsp tool: {tool_name}")), + } +} + +fn ok_json(rt: &ToolRuntime, value: Value) -> ToolExecution { + let mut content = value.to_string(); + let mut truncated = false; + if rt.max_tool_output_bytes > 0 && content.len() > rt.max_tool_output_bytes { + content.truncate(rt.max_tool_output_bytes); + truncated = true; + } + ToolExecution { + ok: true, + content, + truncated, + error: None, + meta: base_meta(rt, SideEffects::FilesystemRead), + } +} + +struct LspSession { + child: Child, + stdin: ChildStdin, + rx: mpsc::Receiver>, + next_id: i64, +} + +impl LspSession { + fn start(config: &super::LspToolRuntime, workdir: &Path) -> Result { + let command = resolve_spawn_command( + config + .command + .as_deref() + .unwrap_or_else(|| Path::new(default_command(config.provider))), + ); + let mut cmd = Command::new(&command); + if matches!(config.provider, LspToolProvider::Typescript) { + cmd.arg("--stdio"); + } + let mut child = cmd + .current_dir(workdir) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .with_context(|| format!("failed spawning LSP command {}", command.display()))?; + let mut stdin = child.stdin.take().context("lsp stdin missing")?; + let stdout = child.stdout.take().context("lsp stdout missing")?; + let (tx, rx) = mpsc::channel::>(); + thread::spawn(move || { + let mut reader = BufReader::new(stdout); + loop { + match read_lsp_message(&mut reader) { + Ok(Some(value)) => { + if tx.send(Ok(value)).is_err() { + break; + } + } + Ok(None) => break, + Err(err) => { + let _ = tx.send(Err(err)); + break; + } + } + } + }); + + write_lsp_message( + &mut stdin, + &json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "processId": null, + "rootUri": path_to_file_uri(workdir), + "capabilities": {} + } + }), + )?; + wait_for_response_id(&rx, 1, INITIALIZE_TIMEOUT_MS)?; + write_lsp_message( + &mut stdin, + &json!({ + "jsonrpc": "2.0", + "method": "initialized", + "params": {} + }), + )?; + + Ok(Self { + child, + stdin, + rx, + next_id: 2, + }) + } + + fn request(&mut self, method: &str, params: Value) -> Result { + let id = self.next_id; + self.next_id += 1; + write_lsp_message( + &mut self.stdin, + &json!({ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params + }), + )?; + wait_for_response_id(&self.rx, id, RESPONSE_TIMEOUT_MS) + } + + fn open_document(&mut self, path: &Path, provider: LspToolProvider) -> Result<()> { + let language_id = language_id_for_path(path, provider) + .ok_or_else(|| anyhow!("unsupported source file for provider: {}", path.display()))?; + let text = fs::read_to_string(path) + .with_context(|| format!("failed reading source file {}", path.display()))?; + write_lsp_message( + &mut self.stdin, + &json!({ + "jsonrpc": "2.0", + "method": "textDocument/didOpen", + "params": { + "textDocument": { + "uri": path_to_file_uri(path), + "languageId": language_id, + "version": 1, + "text": text + } + } + }), + ) + } + + fn collect_diagnostics(&mut self) -> Result> { + let started = std::time::Instant::now(); + let mut last_diagnostics_at: Option = None; + let mut mapped = Vec::::new(); + loop { + let elapsed = started.elapsed(); + if elapsed >= Duration::from_millis(DIAGNOSTICS_TOTAL_TIMEOUT_MS) { + break; + } + let timeout = if let Some(last_at) = last_diagnostics_at { + let idle_elapsed = last_at.elapsed(); + if idle_elapsed >= Duration::from_millis(DIAGNOSTICS_IDLE_AFTER_FIRST_MS) { + break; + } + let idle_remaining = Duration::from_millis(DIAGNOSTICS_IDLE_AFTER_FIRST_MS) + .saturating_sub(idle_elapsed); + let total_remaining = + Duration::from_millis(DIAGNOSTICS_TOTAL_TIMEOUT_MS).saturating_sub(elapsed); + std::cmp::min(idle_remaining, total_remaining) + } else { + Duration::from_millis(DIAGNOSTICS_TOTAL_TIMEOUT_MS).saturating_sub(elapsed) + }; + match self.rx.recv_timeout(timeout) { + Ok(Ok(value)) => { + if let Some(diags) = maybe_map_publish_diagnostics(&value) { + if !diags.is_empty() { + last_diagnostics_at = Some(std::time::Instant::now()); + } + mapped.extend( + diags + .into_iter() + .filter_map(|d| serde_json::to_value(d).ok()), + ); + } + } + Ok(Err(err)) => return Err(err), + Err(mpsc::RecvTimeoutError::Timeout) => break, + Err(mpsc::RecvTimeoutError::Disconnected) => break, + } + } + Ok(mapped) + } + + fn document_symbols(&mut self, path: &Path) -> Result> { + let result = self.request( + "textDocument/documentSymbol", + json!({ + "textDocument": { + "uri": path_to_file_uri(path) + } + }), + )?; + Ok(map_document_symbols(path, &result) + .into_iter() + .filter_map(|s| serde_json::to_value(s).ok()) + .collect()) + } + + fn location_request( + &mut self, + method: &str, + path: &Path, + position: (u32, u32), + extra: Option, + ) -> Result> { + let mut params = json!({ + "textDocument": { "uri": path_to_file_uri(path) }, + "position": { "line": position.0, "character": position.1 } + }); + if let (Some(obj), Some(extra_obj)) = ( + params.as_object_mut(), + extra.and_then(|v| v.as_object().cloned()), + ) { + for (key, value) in extra_obj { + obj.insert(key, value); + } + } + let result = self.request(method, params)?; + Ok(map_location_response(&result) + .into_iter() + .filter_map(|s| serde_json::to_value(s).ok()) + .collect()) + } + + fn hover(&mut self, path: &Path, position: (u32, u32)) -> Result { + let result = self.request( + "textDocument/hover", + json!({ + "textDocument": { "uri": path_to_file_uri(path) }, + "position": { "line": position.0, "character": position.1 } + }), + )?; + Ok(json!({ + "contents": hover_contents_to_text(result.get("contents").unwrap_or(&Value::Null)), + "raw": result + })) + } +} + +impl Drop for LspSession { + fn drop(&mut self) { + let id = self.next_id; + let _ = write_lsp_message( + &mut self.stdin, + &json!({ + "jsonrpc": "2.0", + "id": id, + "method": "shutdown", + "params": null + }), + ); + let _ = write_lsp_message( + &mut self.stdin, + &json!({ + "jsonrpc": "2.0", + "method": "exit", + "params": null + }), + ); + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +fn required_path(args: &Value) -> Result<&str> { + args.get("path") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .ok_or_else(|| anyhow!("path is required")) +} + +fn required_zero_based_position(args: &Value) -> Result<(u32, u32)> { + let line = args + .get("line") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow!("line is required"))?; + let column = args + .get("column") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow!("column is required"))?; + Ok(( + (line as u32).saturating_sub(1), + (column as u32).saturating_sub(1), + )) +} + +fn max_results(args: &Value, default: usize) -> usize { + args.get("max_results") + .and_then(|v| v.as_u64()) + .map(|v| v.clamp(1, 1000) as usize) + .unwrap_or(default) +} + +fn truncate_values(mut values: Vec, max: usize) -> Vec { + if values.len() > max { + values.truncate(max); + } + values +} + +fn scoped_source_file(rt: &ToolRuntime, provider: LspToolProvider, path: &str) -> Result { + if !path_is_workdir_scoped(path) && !rt.unsafe_bypass_allow_flags { + return Err(anyhow!( + "path must stay within workdir (no absolute paths or '..' traversal)" + )); + } + let file = rt.workdir.join(path); + if !file.exists() { + return Err(anyhow!("path does not exist: {}", file.display())); + } + if language_id_for_path(&file, provider).is_none() { + return Err(anyhow!( + "unsupported source file for {} provider: {}", + provider_name(provider), + path + )); + } + Ok(file) +} + +fn discover_source_files( + root: &Path, + provider: LspToolProvider, + max_files: usize, +) -> Result> { + let mut found = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + if found.len() >= max_files { + break; + } + let mut entries = fs::read_dir(&dir) + .with_context(|| format!("failed reading directory {}", dir.display()))? + .filter_map(|entry| entry.ok()) + .collect::>(); + entries.sort_by_key(|entry| entry.path()); + for entry in entries { + let path = entry.path(); + let name = entry.file_name().to_string_lossy().to_string(); + if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { + if matches!( + name.as_str(), + ".git" | ".localagent" | "node_modules" | "target" + ) { + continue; + } + stack.push(path); + continue; + } + if language_id_for_path(&path, provider).is_some() { + found.push(path); + if found.len() >= max_files { + break; + } + } + } + } + found.sort(); + Ok(found) +} + +fn provider_name(provider: LspToolProvider) -> &'static str { + match provider { + LspToolProvider::Rust => "rust_analyzer", + LspToolProvider::Typescript => "typescript_language_server", + } +} + +fn default_command(provider: LspToolProvider) -> &'static str { + match provider { + LspToolProvider::Rust => DEFAULT_RUST_LSP_COMMAND, + LspToolProvider::Typescript => DEFAULT_TYPESCRIPT_LSP_COMMAND, + } +} + +fn language_id_for_path(path: &Path, provider: LspToolProvider) -> Option<&'static str> { + match provider { + LspToolProvider::Rust => match path.extension().and_then(|ext| ext.to_str()) { + Some("rs") => Some("rust"), + _ => None, + }, + LspToolProvider::Typescript => match path.extension().and_then(|ext| ext.to_str()) { + Some("ts") => Some("typescript"), + Some("tsx") => Some("typescriptreact"), + Some("js") | Some("mjs") | Some("cjs") => Some("javascript"), + Some("jsx") => Some("javascriptreact"), + _ => None, + }, + } +} + +fn resolve_spawn_command(command: &Path) -> PathBuf { + #[cfg(windows)] + { + if command.extension().is_some() { + return command.to_path_buf(); + } + if command.components().count() > 1 { + let cmd = command.with_extension("cmd"); + if cmd.exists() { + return cmd; + } + return command.to_path_buf(); + } + if let Some(path_os) = std::env::var_os("PATH") { + for dir in std::env::split_paths(&path_os) { + for ext in ["cmd", "exe", "bat"] { + let candidate = dir.join(command).with_extension(ext); + if candidate.exists() { + return candidate; + } + } + let candidate = dir.join(command); + if candidate.exists() { + return candidate; + } + } + } + } + command.to_path_buf() +} + +fn wait_for_response_id( + rx: &mpsc::Receiver>, + expected_id: i64, + timeout_ms: u64, +) -> Result { + let deadline = Duration::from_millis(timeout_ms); + let started = std::time::Instant::now(); + loop { + let remaining = deadline + .checked_sub(started.elapsed()) + .unwrap_or(Duration::from_millis(0)); + match rx.recv_timeout(remaining) { + Ok(Ok(value)) => { + if value.get("id").and_then(|id| id.as_i64()) == Some(expected_id) { + if let Some(error) = value.get("error") { + return Err(anyhow!("lsp response returned error: {}", error)); + } + return Ok(value.get("result").cloned().unwrap_or(Value::Null)); + } + } + Ok(Err(err)) => return Err(err), + Err(mpsc::RecvTimeoutError::Timeout) => { + return Err(anyhow!("timed out waiting for lsp response {expected_id}")) + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + return Err(anyhow!( + "language server exited before response {expected_id}" + )) + } + } + } +} + +fn write_lsp_message(writer: &mut W, value: &Value) -> Result<()> { + let body = serde_json::to_vec(value).context("failed serializing lsp request")?; + writer + .write_all(format!("Content-Length: {}\r\n\r\n", body.len()).as_bytes()) + .context("failed writing lsp header")?; + writer.write_all(&body).context("failed writing lsp body")?; + writer.flush().context("failed flushing lsp message")?; + Ok(()) +} + +fn read_lsp_message(reader: &mut R) -> Result> { + let mut content_length = None; + loop { + let mut line = String::new(); + let read = reader + .read_line(&mut line) + .context("failed reading lsp header line")?; + if read == 0 { + return Ok(None); + } + if line == "\r\n" || line == "\n" { + break; + } + if let Some(value) = line.strip_prefix("Content-Length:") { + content_length = Some( + value + .trim() + .parse::() + .context("invalid content length")?, + ); + } + } + let len = content_length.context("missing Content-Length header")?; + let mut buf = vec![0_u8; len]; + reader + .read_exact(&mut buf) + .context("failed reading lsp body")?; + Ok(Some( + serde_json::from_slice(&buf).context("invalid lsp json body")?, + )) +} + +fn path_to_file_uri(path: &Path) -> String { + let absolute = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + let rendered = absolute.to_string_lossy().replace('\\', "/"); + #[cfg(windows)] + let rendered = rendered + .strip_prefix("//?/") + .unwrap_or(rendered.as_str()) + .to_string(); + if rendered.starts_with('/') { + format!("file://{rendered}") + } else { + format!("file:///{rendered}") + } +} + +fn file_uri_to_path(uri: &str) -> Option { + let trimmed = uri + .strip_prefix("file:///") + .or_else(|| uri.strip_prefix("file://"))?; + let normalized = if trimmed.len() >= 3 && trimmed.as_bytes().get(1) == Some(&b':') { + trimmed.to_string() + } else { + format!("/{trimmed}") + }; + Some(PathBuf::from( + normalized.replace('/', std::path::MAIN_SEPARATOR_STR), + )) +} + +fn maybe_map_publish_diagnostics(value: &Value) -> Option> { + let method = value.get("method")?.as_str()?; + if method != "textDocument/publishDiagnostics" { + return None; + } + let params = value.get("params")?.clone(); + let payload: PublishDiagnosticsParams = serde_json::from_value(params).ok()?; + let path = file_uri_to_path(&payload.uri); + let mut mapped = Vec::new(); + for item in payload.diagnostics { + mapped.push(Diagnostic { + schema_version: DIAGNOSTIC_SCHEMA_VERSION.to_string(), + code: diagnostic_code_to_string(item.code), + severity: map_severity(item.severity), + message: item.message, + path: path.clone(), + line: item.range.start.line.checked_add(1), + col: item.range.start.character.checked_add(1), + hint: item.source.clone(), + details: Some(json!({ + "source": item.source, + "range": { + "start": { + "line": item.range.start.line, + "character": item.range.start.character + }, + "end": { + "line": item.range.end.line, + "character": item.range.end.character + } + } + })), + }); + } + Some(mapped) +} + +fn diagnostic_code_to_string(code: Option) -> String { + match code { + Some(Value::String(s)) => s, + Some(Value::Number(n)) => n.to_string(), + Some(other) => other.to_string(), + None => "lsp_diagnostic".to_string(), + } +} + +fn map_severity(severity: Option) -> Severity { + match severity.unwrap_or(1) { + 1 => Severity::Error, + 2 => Severity::Warning, + _ => Severity::Info, + } +} + +fn map_document_symbols(path: &Path, result: &Value) -> Vec { + let mut out = Vec::new(); + if let Value::Array(items) = result { + for item in items { + if item.get("selectionRange").is_some() { + collect_document_symbol_tree(path, item, &mut out); + } else if let Some(symbol) = map_symbol_information(item) { + out.push(symbol); + } + } + } + sort_symbols(&mut out); + out +} + +fn collect_document_symbol_tree(path: &Path, item: &Value, out: &mut Vec) { + if let Some(symbol) = map_document_symbol(path, item) { + out.push(symbol); + } + if let Some(children) = item.get("children").and_then(|c| c.as_array()) { + for child in children { + collect_document_symbol_tree(path, child, out); + } + } +} + +fn map_document_symbol(path: &Path, item: &Value) -> Option { + let label = item.get("name")?.as_str()?.to_string(); + let selection_range = item.get("selectionRange").or_else(|| item.get("range"))?; + let start = selection_range.get("start")?; + Some(SymbolLocation { + path: path.to_path_buf(), + line: start + .get("line") + .and_then(|v| v.as_u64()) + .map(|n| n as u32 + 1), + col: start + .get("character") + .and_then(|v| v.as_u64()) + .map(|n| n as u32 + 1), + label, + }) +} + +fn map_symbol_information(item: &Value) -> Option { + let label = item.get("name")?.as_str()?.to_string(); + let location = item.get("location")?; + let uri = location.get("uri")?.as_str()?; + let path = file_uri_to_path(uri)?; + let start = location.get("range")?.get("start")?; + Some(SymbolLocation { + path, + line: start + .get("line") + .and_then(|v| v.as_u64()) + .map(|n| n as u32 + 1), + col: start + .get("character") + .and_then(|v| v.as_u64()) + .map(|n| n as u32 + 1), + label, + }) +} + +fn map_location_response(result: &Value) -> Vec { + let mut out = match result { + Value::Array(items) => items.iter().filter_map(map_location_like).collect(), + Value::Object(_) => map_location_like(result).into_iter().collect(), + _ => Vec::new(), + }; + sort_symbols(&mut out); + out +} + +fn map_location_like(value: &Value) -> Option { + if let Some(uri) = value.get("uri").and_then(|v| v.as_str()) { + let path = file_uri_to_path(uri)?; + let start = value.get("range")?.get("start")?; + return Some(SymbolLocation { + path, + line: start + .get("line") + .and_then(|v| v.as_u64()) + .map(|n| n as u32 + 1), + col: start + .get("character") + .and_then(|v| v.as_u64()) + .map(|n| n as u32 + 1), + label: "location".to_string(), + }); + } + if let Some(uri) = value.get("targetUri").and_then(|v| v.as_str()) { + let path = file_uri_to_path(uri)?; + let start = value.get("targetRange")?.get("start")?; + return Some(SymbolLocation { + path, + line: start + .get("line") + .and_then(|v| v.as_u64()) + .map(|n| n as u32 + 1), + col: start + .get("character") + .and_then(|v| v.as_u64()) + .map(|n| n as u32 + 1), + label: "location".to_string(), + }); + } + None +} + +fn sort_symbols(items: &mut [SymbolLocation]) { + items.sort_by(|a, b| { + ( + a.path.to_string_lossy().to_string(), + a.line.unwrap_or(0), + a.col.unwrap_or(0), + a.label.clone(), + ) + .cmp(&( + b.path.to_string_lossy().to_string(), + b.line.unwrap_or(0), + b.col.unwrap_or(0), + b.label.clone(), + )) + }); +} + +fn hover_contents_to_text(value: &Value) -> String { + match value { + Value::String(s) => s.clone(), + Value::Array(items) => items + .iter() + .map(hover_contents_to_text) + .filter(|s| !s.is_empty()) + .collect::>() + .join("\n"), + Value::Object(obj) => obj + .get("value") + .or_else(|| obj.get("contents")) + .map(hover_contents_to_text) + .unwrap_or_default(), + _ => String::new(), + } +} + +#[derive(Debug, Deserialize)] +struct PublishDiagnosticsParams { + uri: String, + diagnostics: Vec, +} + +#[derive(Debug, Deserialize)] +struct LspDiagnostic { + range: LspRange, + severity: Option, + code: Option, + source: Option, + message: String, +} + +#[derive(Debug, Deserialize)] +struct LspRange { + start: LspPosition, + end: LspPosition, +} + +#[derive(Debug, Deserialize)] +struct LspPosition { + line: u32, + character: u32, +} diff --git a/src/tools/schema.rs b/src/tools/schema.rs index 4b27a74..6d9e32f 100644 --- a/src/tools/schema.rs +++ b/src/tools/schema.rs @@ -28,6 +28,51 @@ pub fn compact_builtin_schema(tool_name: &str) -> Option { "ignore_case":{"type":"boolean"} } })), + "lsp.diagnostics" => Some(json!({ + "type":"object", + "properties":{ + "path":{"type":"string"}, + "max_results":{"type":"integer","minimum":1,"maximum":1000} + } + })), + "lsp.document_symbols" => Some(json!({ + "type":"object", + "required":["path"], + "properties":{ + "path":{"type":"string"}, + "max_results":{"type":"integer","minimum":1,"maximum":1000} + } + })), + "lsp.goto_definition" => Some(json!({ + "type":"object", + "required":["path","line","column"], + "properties":{ + "path":{"type":"string"}, + "line":{"type":"integer","minimum":1}, + "column":{"type":"integer","minimum":1}, + "max_results":{"type":"integer","minimum":1,"maximum":1000} + } + })), + "lsp.find_references" => Some(json!({ + "type":"object", + "required":["path","line","column"], + "properties":{ + "path":{"type":"string"}, + "line":{"type":"integer","minimum":1}, + "column":{"type":"integer","minimum":1}, + "include_declaration":{"type":"boolean"}, + "max_results":{"type":"integer","minimum":1,"maximum":1000} + } + })), + "lsp.hover" => Some(json!({ + "type":"object", + "required":["path","line","column"], + "properties":{ + "path":{"type":"string"}, + "line":{"type":"integer","minimum":1}, + "column":{"type":"integer","minimum":1} + } + })), "update_plan" => Some(json!({ "type":"object", "required":["items"], @@ -104,6 +149,15 @@ pub fn minimal_builtin_example(tool_name: &str) -> Option { "read_file" => Some(json!({"path":"src/main.rs"})), "glob" => Some(json!({"pattern":"src/**/*.rs","path":".","max_results":200})), "grep" => Some(json!({"pattern":"TODO","path":".","max_results":200,"ignore_case":false})), + "lsp.diagnostics" => Some(json!({"path":"src/main.rs","max_results":100})), + "lsp.document_symbols" => Some(json!({"path":"src/main.rs","max_results":100})), + "lsp.goto_definition" => { + Some(json!({"path":"src/main.rs","line":1,"column":1,"max_results":20})) + } + "lsp.find_references" => Some( + json!({"path":"src/main.rs","line":1,"column":1,"include_declaration":true,"max_results":100}), + ), + "lsp.hover" => Some(json!({"path":"src/main.rs","line":1,"column":1})), "update_plan" => Some( json!({"items":[{"step":"Inspect the code","status":"in_progress"},{"step":"Run tests","status":"pending"}]}), ), @@ -125,6 +179,11 @@ pub fn sorted_builtin_tool_names() -> Vec { "list_dir".to_string(), "glob".to_string(), "grep".to_string(), + "lsp.diagnostics".to_string(), + "lsp.document_symbols".to_string(), + "lsp.find_references".to_string(), + "lsp.goto_definition".to_string(), + "lsp.hover".to_string(), "update_plan".to_string(), "read_file".to_string(), "edit".to_string(), @@ -196,6 +255,40 @@ pub fn validate_builtin_tool_args( } } } + "lsp.diagnostics" => { + if let Some(v) = obj.get("path") { + if v.as_str().is_none() { + return Err("path must be a string".to_string()); + } + } + validate_optional_max_results(obj)?; + } + "lsp.document_symbols" => { + require_non_empty_string(obj, "path")?; + validate_optional_max_results(obj)?; + } + "lsp.goto_definition" => { + require_non_empty_string(obj, "path")?; + require_positive_u64(obj, "line")?; + require_positive_u64(obj, "column")?; + validate_optional_max_results(obj)?; + } + "lsp.find_references" => { + require_non_empty_string(obj, "path")?; + require_positive_u64(obj, "line")?; + require_positive_u64(obj, "column")?; + if let Some(v) = obj.get("include_declaration") { + if v.as_bool().is_none() { + return Err("include_declaration must be a boolean".to_string()); + } + } + validate_optional_max_results(obj)?; + } + "lsp.hover" => { + require_non_empty_string(obj, "path")?; + require_positive_u64(obj, "line")?; + require_positive_u64(obj, "column")?; + } "update_plan" => { super::exec_plan::parse_update_plan_args(args).map(|_| ())?; } @@ -343,3 +436,26 @@ fn require_non_empty_string(obj: &serde_json::Map, key: &str) -> } Ok(()) } + +fn require_positive_u64(obj: &serde_json::Map, key: &str) -> Result<(), String> { + let n = obj + .get(key) + .and_then(|v| v.as_u64()) + .ok_or_else(|| format!("{key} must be a positive integer"))?; + if n == 0 { + return Err(format!("{key} must be a positive integer")); + } + Ok(()) +} + +fn validate_optional_max_results(obj: &serde_json::Map) -> Result<(), String> { + if let Some(v) = obj.get("max_results") { + let n = v + .as_u64() + .ok_or_else(|| "max_results must be an integer".to_string())?; + if !(1..=1000).contains(&n) { + return Err("max_results must be between 1 and 1000".to_string()); + } + } + Ok(()) +} diff --git a/src/tools/tests.rs b/src/tools/tests.rs index 83bb8b7..84e8d9a 100644 --- a/src/tools/tests.rs +++ b/src/tools/tests.rs @@ -4,11 +4,11 @@ use serde_json::{json, Value}; use tempfile::tempdir; use super::{ - builtin_tools_enabled, execute_tool, load_tool_result_artifact_manifest, - resolve_tool_result_artifact_ref, to_model_observation_message, to_tool_result_envelope, - to_tool_result_envelope_with_error, tool_side_effects, validate_builtin_tool_args, - validate_schema_args, ToolArgsStrict, ToolErrorCode, ToolErrorDetail, ToolResultContentRef, - ToolResultMeta, ToolRuntime, + builtin_tools_enabled, builtin_tools_enabled_with_lsp, execute_tool, + load_tool_result_artifact_manifest, resolve_tool_result_artifact_ref, + to_model_observation_message, to_tool_result_envelope, to_tool_result_envelope_with_error, + tool_side_effects, validate_builtin_tool_args, validate_schema_args, ToolArgsStrict, + ToolErrorCode, ToolErrorDetail, ToolResultContentRef, ToolResultMeta, ToolRuntime, }; use crate::target::{ExecTargetKind, HostTarget}; use crate::types::{SideEffects, ToolCall}; @@ -44,6 +44,22 @@ fn write_tools_not_exposed_by_default() { assert!(!names.iter().any(|n| n == "edit")); assert!(!names.iter().any(|n| n == "write_file")); assert!(!names.iter().any(|n| n == "apply_patch")); + assert!(!names.iter().any(|n| n == "lsp.diagnostics")); +} + +#[test] +fn lsp_tools_exposed_only_when_enabled() { + let tools = builtin_tools_enabled_with_lsp(false, false, true); + let names = tools.into_iter().map(|t| t.name).collect::>(); + for expected in [ + "lsp.diagnostics", + "lsp.document_symbols", + "lsp.goto_definition", + "lsp.find_references", + "lsp.hover", + ] { + assert!(names.iter().any(|n| n == expected)); + } } #[test] @@ -96,6 +112,11 @@ fn builtin_tool_order_biases_toward_read_edit_verify_validate_flow() { "list_dir", "glob", "grep", + "lsp.diagnostics", + "lsp.document_symbols", + "lsp.find_references", + "lsp.goto_definition", + "lsp.hover", "update_plan", "read_file", "edit", @@ -115,6 +136,10 @@ fn side_effects_map_builtin_and_mcp() { assert_eq!(tool_side_effects("list_dir"), SideEffects::FilesystemRead); assert_eq!(tool_side_effects("glob"), SideEffects::FilesystemRead); assert_eq!(tool_side_effects("grep"), SideEffects::FilesystemRead); + assert_eq!( + tool_side_effects("lsp.goto_definition"), + SideEffects::FilesystemRead + ); assert_eq!(tool_side_effects("update_plan"), SideEffects::None); assert_eq!( tool_side_effects("mcp.playwright.browser_snapshot"), @@ -134,6 +159,7 @@ async fn update_plan_returns_compact_side_effect_free_result() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }; @@ -427,6 +453,7 @@ async fn write_file_denied_when_allow_write_false() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }; @@ -453,6 +480,7 @@ async fn invalid_args_do_not_write_file() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }; @@ -486,6 +514,7 @@ async fn invalid_args_payload_is_structured_and_deterministic() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }; @@ -533,6 +562,7 @@ async fn unknown_tool_payload_includes_sorted_available_tools() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }; @@ -562,6 +592,11 @@ async fn unknown_tool_payload_includes_sorted_available_tools() { json!("glob"), json!("grep"), json!("list_dir"), + json!("lsp.diagnostics"), + json!("lsp.document_symbols"), + json!("lsp.find_references"), + json!("lsp.goto_definition"), + json!("lsp.hover"), json!("read_file"), json!("shell"), json!("str_replace"), @@ -586,6 +621,7 @@ async fn glob_returns_sorted_matches_and_truncates() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }; @@ -626,6 +662,7 @@ async fn grep_returns_byte_columns_multi_match_and_skips_non_utf8() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }; @@ -687,6 +724,7 @@ async fn glob_rejects_out_of_scope_path() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }; @@ -726,6 +764,7 @@ async fn grep_symlink_out_of_scope_adds_warning_metadata() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }; @@ -766,6 +805,7 @@ async fn write_file_blocks_existing_file_without_overwrite_flag() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }; @@ -796,6 +836,7 @@ async fn write_file_allows_existing_file_with_overwrite_flag() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }; @@ -874,6 +915,7 @@ async fn apply_patch_updates_file() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }; @@ -901,6 +943,7 @@ async fn edit_updates_file_with_opencode_style_aliases() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }; @@ -932,6 +975,7 @@ async fn read_file_envelope_sets_truncation() { max_read_bytes: 5, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }; @@ -966,6 +1010,7 @@ async fn shell_in_workdir_flag_rejects_escaping_cwd() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }; @@ -1002,6 +1047,7 @@ async fn shell_disabled_uses_shell_gate_deny_code() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }; @@ -1034,6 +1080,7 @@ async fn shell_spawn_not_found_sets_not_found_error_code() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }; @@ -1068,6 +1115,7 @@ async fn shell_auto_repair_wraps_windows_builtin() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }; @@ -1121,6 +1169,7 @@ async fn shell_auto_repair_uses_sh_lc_for_embedded_command() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }; @@ -1168,6 +1217,7 @@ async fn shell_without_timeout_still_runs_fast_command_under_default_policy() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }; @@ -1210,6 +1260,7 @@ async fn read_file_rejects_path_traversal() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }; @@ -1245,6 +1296,7 @@ async fn write_file_rejects_absolute_path() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }; @@ -1274,6 +1326,7 @@ async fn str_replace_not_found_suggests_apply_patch_recovery() { max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: std::sync::Arc::new(HostTarget), }; diff --git a/src/trust/policy.rs b/src/trust/policy.rs index 225c8c6..87e6411 100644 --- a/src/trust/policy.rs +++ b/src/trust/policy.rs @@ -240,6 +240,20 @@ impl Policy { path: "safe_default".to_string(), }, }, + CompiledRule { + tool_pattern: "lsp.*".to_string(), + tool: ToolMatcher::Glob( + Glob::new("lsp.*") + .expect("valid safe default glob") + .compile_matcher(), + ), + decision: PolicyDecision::Allow, + when: Vec::new(), + reason: None, + source: RuleSource { + path: "safe_default".to_string(), + }, + }, CompiledRule { tool_pattern: "shell".to_string(), tool: ToolMatcher::Exact("shell".to_string()), @@ -387,7 +401,7 @@ impl Policy { } pub fn safe_default_policy_repr() -> &'static str { - "version:1;default:deny;rules:[allow list_dir,allow read_file,allow glob,allow grep,require_approval shell,require_approval write_file,require_approval apply_patch,require_approval edit,require_approval str_replace]" + "version:1;default:deny;rules:[allow list_dir,allow read_file,allow glob,allow grep,allow lsp.*,require_approval shell,require_approval write_file,require_approval apply_patch,require_approval edit,require_approval str_replace]" } #[derive(Default)] @@ -833,12 +847,18 @@ taint: policy.evaluate("grep", &json!({"pattern":"TODO"})).decision, PolicyDecision::Allow ); + assert_eq!( + policy + .evaluate("lsp.diagnostics", &json!({"path":"src/main.rs"})) + .decision, + PolicyDecision::Allow + ); } #[test] fn safe_default_policy_repr_includes_glob_and_grep_in_order() { let repr = super::safe_default_policy_repr(); - let expected = "version:1;default:deny;rules:[allow list_dir,allow read_file,allow glob,allow grep,require_approval shell,require_approval write_file,require_approval apply_patch,require_approval edit,require_approval str_replace]"; + let expected = "version:1;default:deny;rules:[allow list_dir,allow read_file,allow glob,allow grep,allow lsp.*,require_approval shell,require_approval write_file,require_approval apply_patch,require_approval edit,require_approval str_replace]"; assert_eq!(repr, expected); } } diff --git a/tests/lsp_tools.rs b/tests/lsp_tools.rs new file mode 100644 index 0000000..dece420 --- /dev/null +++ b/tests/lsp_tools.rs @@ -0,0 +1,109 @@ +use std::fs; +use std::path::PathBuf; + +use localagent::target::{ExecTargetKind, HostTarget}; +use localagent::tools::{ + execute_tool, LspToolProvider, LspToolRuntime, ToolArgsStrict, ToolRuntime, +}; +use localagent::types::ToolCall; +use serde_json::{json, Value}; +use tempfile::tempdir; + +fn runtime(workdir: PathBuf, provider: LspToolProvider) -> ToolRuntime { + ToolRuntime { + workdir, + allow_shell: false, + allow_shell_in_workdir_only: false, + allow_write: false, + max_tool_output_bytes: 200_000, + max_read_bytes: 200_000, + unsafe_bypass_allow_flags: false, + tool_args_strict: ToolArgsStrict::On, + lsp: Some(LspToolRuntime { + provider, + command: Some(PathBuf::from(env!("CARGO_BIN_EXE_lsp_stub"))), + }), + exec_target_kind: ExecTargetKind::Host, + exec_target: std::sync::Arc::new(HostTarget), + } +} + +async fn call(rt: &ToolRuntime, name: &str, arguments: Value) -> Value { + let msg = execute_tool( + rt, + &ToolCall { + id: format!("tc_{name}"), + name: name.to_string(), + arguments, + }, + ) + .await; + let outer: Value = serde_json::from_str(&msg.content.expect("tool content")).expect("outer"); + assert_eq!(outer["ok"], json!(true)); + serde_json::from_str(outer["content"].as_str().expect("inner content")).expect("inner") +} + +#[tokio::test] +async fn lsp_tools_call_stub_language_server_read_only() { + let tmp = tempdir().expect("tmp"); + fs::create_dir_all(tmp.path().join("src")).expect("mkdir src"); + fs::write( + tmp.path().join("src").join("index.ts"), + "const value: number = \"oops\";\n", + ) + .expect("write source"); + let rt = runtime(tmp.path().to_path_buf(), LspToolProvider::Typescript); + + let diagnostics = call( + &rt, + "lsp.diagnostics", + json!({"path":"src/index.ts","max_results":10}), + ) + .await; + assert_eq!(diagnostics["diagnostic_count"], json!(1)); + assert_eq!(diagnostics["diagnostics"][0]["code"], json!("2322")); + + let symbols = call(&rt, "lsp.document_symbols", json!({"path":"src/index.ts"})).await; + assert_eq!(symbols["symbols"][0]["label"], json!("value")); + + let definitions = call( + &rt, + "lsp.goto_definition", + json!({"path":"src/index.ts","line":1,"column":7}), + ) + .await; + assert_eq!(definitions["definition_count"], json!(1)); + + let references = call( + &rt, + "lsp.find_references", + json!({"path":"src/index.ts","line":1,"column":7}), + ) + .await; + assert_eq!(references["reference_count"], json!(2)); + + let hover = call( + &rt, + "lsp.hover", + json!({"path":"src/index.ts","line":1,"column":7}), + ) + .await; + assert_eq!(hover["hover"]["contents"], json!("const value: number")); +} + +#[tokio::test] +async fn rust_lsp_provider_accepts_rust_source_files() { + let tmp = tempdir().expect("tmp"); + fs::create_dir_all(tmp.path().join("src")).expect("mkdir src"); + fs::write( + tmp.path().join("src").join("lib.rs"), + "const VALUE: i32 = 1;\n", + ) + .expect("write source"); + let rt = runtime(tmp.path().to_path_buf(), LspToolProvider::Rust); + + let symbols = call(&rt, "lsp.document_symbols", json!({"path":"src/lib.rs"})).await; + + assert_eq!(symbols["provider"], json!("rust_analyzer")); + assert_eq!(symbols["symbols"][0]["label"], json!("value")); +} diff --git a/tests/mcp_impl_regression.rs b/tests/mcp_impl_regression.rs index 11d8321..b764cab 100644 --- a/tests/mcp_impl_regression.rs +++ b/tests/mcp_impl_regression.rs @@ -200,6 +200,7 @@ fn make_agent_with_mcp( max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: Arc::new(HostTarget), }, diff --git a/tests/tool_call_accuracy_ci.rs b/tests/tool_call_accuracy_ci.rs index 24f2179..0165c2d 100644 --- a/tests/tool_call_accuracy_ci.rs +++ b/tests/tool_call_accuracy_ci.rs @@ -138,6 +138,7 @@ fn build_agent( max_read_bytes: 200_000, unsafe_bypass_allow_flags: false, tool_args_strict: ToolArgsStrict::On, + lsp: None, exec_target_kind: ExecTargetKind::Host, exec_target: Arc::new(HostTarget), },