From 47ad905f5bfb110aef40f766e7fc254107d2bb7f Mon Sep 17 00:00:00 2001 From: ykiko Date: Mon, 4 May 2026 00:15:37 +0800 Subject: [PATCH] feat(server): agentic query API, daemon mode, and relay mode (#438) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - **Agentic query API**: 11 JSON-RPC handlers over TCP for AI agent integration — compileCommand, projectFiles, fileDeps, impactAnalysis, symbolSearch, readSymbol, documentSymbols, definition, references, callGraph, typeHierarchy - **Daemon mode**: MasterServer listens on a unix domain socket, accepting multiple agent connections (`--mode daemon`) - **Relay mode**: Bidirectional stdin/stdout ↔ unix socket proxy for editor integration (`--mode relay`) - **Full-body definition text**: readSymbol/definition return complete function/class bodies via brace matching, not just the declaration line - **24 integration tests** with concrete value assertions covering all handlers ## Test plan - [x] All 551 unit tests pass - [x] All 2 smoke tests pass - [x] All 148 integration tests pass (including 24 new agentic tests) - [x] Raw JSON responses manually inspected for correctness (line numbers, text content, field names, structural completeness) - [x] Formatted with `pixi run format` 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Summary by CodeRabbit * **New Features** * Daemon mode: background service with workspace watching and socket support * Relay mode: stdio ↔ Unix-socket message relay * Expanded agentic RPC/CLI: project files, deps/impact, symbol search/read, document symbols, definitions, references, call graphs, type hierarchy, status, shutdown; richer query options * **Behavioral** * Improved indexing lifecycle and status reporting; safer shutdown coordination and client handling * **Tests** * New integration tests covering agentic RPC endpoints, CLI status, shutdown, and error cases --------- Co-authored-by: Claude Opus 4.6 --- src/clice.cc | 83 ++- src/server/compiler/indexer.cpp | 233 +++++-- src/server/compiler/indexer.h | 42 +- src/server/protocol/agentic.h | 267 ++++++++ src/server/service/agent_client.cpp | 754 +++++++++++++++++++++- src/server/service/agentic.cpp | 150 ++++- src/server/service/agentic.h | 15 +- src/server/service/lsp_client.cpp | 4 +- src/server/service/master_server.cpp | 168 ++++- src/server/service/master_server.h | 16 + src/server/worker/worker_pool.cpp | 11 +- src/server/worker/worker_pool.h | 1 + src/support/filesystem.h | 8 + tests/integration/agentic/test_agentic.py | 511 ++++++++++++++- tests/integration/agentic/test_cli.py | 189 ++++++ 15 files changed, 2362 insertions(+), 90 deletions(-) create mode 100644 tests/integration/agentic/test_cli.py diff --git a/src/clice.cc b/src/clice.cc index 0f0bfcb7..fccae05e 100644 --- a/src/clice.cc +++ b/src/clice.cc @@ -17,9 +17,11 @@ namespace clice { using kota::deco::decl::KVStyle; struct Options { - DecoKV(style = KVStyle::JoinedOrSeparate, - help = "Running mode: pipe, socket, agentic, stateless-worker, stateful-worker", - required = false) + DecoKV( + style = KVStyle::JoinedOrSeparate, + help = + "Running mode: pipe, socket, daemon, relay, agentic, stateless-worker, stateful-worker", + required = false) mode; DecoKV(style = KVStyle::JoinedOrSeparate, help = "Socket mode address", required = false) @@ -46,6 +48,45 @@ struct Options { required = false) path; + DecoKV( + style = KVStyle::JoinedOrSeparate, + help = + "Agentic method (compileCommand, symbolSearch, definition, references, " + "documentSymbols, readSymbol, callGraph, typeHierarchy, projectFiles, " + "fileDeps, impactAnalysis, status, shutdown)", + required = false) + method; + + DecoKV(style = KVStyle::JoinedOrSeparate, + help = "Symbol name for agentic queries", + required = false) + name; + + DecoKV(style = KVStyle::JoinedOrSeparate, + help = "Search query for symbolSearch", + required = false) + query; + + DecoKV(style = KVStyle::JoinedOrSeparate, + help = "Line number for position-based lookup", + required = false) + line; + + DecoKV(style = KVStyle::JoinedOrSeparate, + help = "Direction: callers/callees or supertypes/subtypes", + required = false) + direction; + + DecoKV(style = KVStyle::JoinedOrSeparate, + help = "Unix domain socket path for daemon mode", + required = false) + socket; + + DecoKV(style = KVStyle::JoinedOrSeparate, + help = "Workspace root directory for daemon mode", + required = false) + workspace; + // Internal options (passed from master to worker processes) DecoKV(style = KVStyle::JoinedOrSeparate, names = {"--worker-memory-limit", "--worker-memory-limit="}, @@ -139,19 +180,41 @@ int main(int argc, const char** argv) { return clice::run_server_mode(server_opts); } + if(mode == "daemon") { + auto workspace = opts.workspace.value_or(""); + if(workspace.empty()) { + LOG_ERROR("--workspace is required for daemon mode"); + return 1; + } + + clice::DaemonOptions daemon_opts; + daemon_opts.socket_path = opts.socket.value_or(""); + daemon_opts.workspace = std::move(workspace); + daemon_opts.self_path = argv[0]; + return clice::run_daemon_mode(daemon_opts); + } + if(mode == "agentic") { - auto host = opts.host.value_or("127.0.0.1"); auto port = opts.port.value_or(0); - auto path = opts.path.value_or(""); if(port <= 0) { LOG_ERROR("--port is required for agentic mode"); return 1; } - if(path.empty()) { - LOG_ERROR("--path is required for agentic mode"); - return 1; - } - return clice::run_agentic_mode(host, port, path); + clice::AgenticQueryOptions aq; + aq.host = opts.host.value_or("127.0.0.1"); + aq.port = port; + aq.method = opts.method.value_or("compileCommand"); + aq.path = opts.path.value_or(""); + aq.name = opts.name.value_or(""); + aq.query = opts.query.value_or(""); + aq.line = opts.line.value_or(0); + aq.direction = opts.direction.value_or(""); + return clice::run_agentic_mode(aq); + } + + if(mode == "relay") { + auto socket = opts.socket.value_or(""); + return clice::run_relay_mode(socket); } LOG_ERROR("unknown mode '{}'", mode); diff --git a/src/server/compiler/indexer.cpp b/src/server/compiler/indexer.cpp index 4ef2b65f..4248574e 100644 --- a/src/server/compiler/indexer.cpp +++ b/src/server/compiler/indexer.cpp @@ -447,6 +447,152 @@ std::optional Indexer::resolve_symbol(index::SymbolHash hash) { return SymbolInfo{hash, std::move(name), kind, def_loc->uri, def_loc->range}; } +static std::string extract_line(llvm::StringRef content, std::uint32_t offset) { + if(content.empty() || offset >= content.size()) + return {}; + std::size_t line_start = 0; + if(offset > 0) { + auto pos = content.rfind('\n', offset - 1); + if(pos != llvm::StringRef::npos) + line_start = pos + 1; + } + auto line_end = content.find('\n', offset); + if(line_end == llvm::StringRef::npos) + line_end = content.size(); + return content.slice(line_start, line_end).str(); +} + +std::optional Indexer::get_definition_text(index::SymbolHash hash) { + for(auto& [id, sess]: sessions) { + if(!sess.file_index || !sess.file_index->mapper) + continue; + auto it = sess.file_index->file_index.relations.find(hash); + if(it == sess.file_index->file_index.relations.end()) + continue; + for(auto& rel: it->second) { + if(rel.kind.value() != RelationKind::Definition) + continue; + auto def_range = std::bit_cast(rel.target_symbol); + if(def_range.begin >= def_range.end) + continue; + llvm::StringRef content = sess.file_index->content; + if(def_range.end > content.size()) + continue; + auto start = sess.file_index->mapper->to_position(def_range.begin); + auto end = sess.file_index->mapper->to_position(def_range.end); + if(!start || !end) + continue; + return DefinitionText{ + .file = std::string(workspace.path_pool.resolve(id)), + .start_line = static_cast(start->line) + 1, + .end_line = static_cast(end->line) + 1, + .text = + std::string(content.substr(def_range.begin, def_range.end - def_range.begin)), + }; + } + } + + auto sym_it = workspace.project_index.symbols.find(hash); + if(sym_it == workspace.project_index.symbols.end()) + return std::nullopt; + + for(auto file_id: sym_it->second.reference_files) { + if(is_proj_path_open(file_id)) + continue; + auto shard_it = workspace.merged_indices.find(file_id); + if(shard_it == workspace.merged_indices.end()) + continue; + auto* m = shard_it->second.mapper(); + if(!m) + continue; + auto content = shard_it->second.index.content(); + + std::optional result; + shard_it->second.index.lookup( + hash, + RelationKind::Definition, + [&](const index::Relation& r) { + auto def_range = std::bit_cast(r.target_symbol); + if(def_range.begin >= def_range.end || def_range.end > content.size()) + return true; + auto start = m->to_position(def_range.begin); + auto end = m->to_position(def_range.end); + if(!start || !end) + return true; + result = DefinitionText{ + .file = workspace.project_index.path_pool.path(file_id).str(), + .start_line = static_cast(start->line) + 1, + .end_line = static_cast(end->line) + 1, + .text = std::string( + content.substr(def_range.begin, def_range.end - def_range.begin)), + }; + return false; + }); + if(result) + return result; + } + + return std::nullopt; +} + +std::vector Indexer::collect_references(index::SymbolHash hash, + RelationKind kind) { + std::vector results; + + auto sym_it = workspace.project_index.symbols.find(hash); + if(sym_it != workspace.project_index.symbols.end()) { + for(auto file_id: sym_it->second.reference_files) { + if(is_proj_path_open(file_id)) + continue; + auto shard_it = workspace.merged_indices.find(file_id); + if(shard_it == workspace.merged_indices.end()) + continue; + auto* m = shard_it->second.mapper(); + if(!m) + continue; + auto content = shard_it->second.index.content(); + auto file_path = workspace.project_index.path_pool.path(file_id); + + shard_it->second.index.lookup(hash, kind, [&](const index::Relation& r) { + auto start = m->to_position(r.range.begin); + if(!start) + return true; + results.push_back(ReferenceWithContext{ + .file = file_path.str(), + .line = static_cast(start->line) + 1, + .context = extract_line(content, r.range.begin), + }); + return true; + }); + } + } + + for(auto& [id, sess]: sessions) { + if(!sess.file_index || !sess.file_index->mapper) + continue; + auto it = sess.file_index->file_index.relations.find(hash); + if(it == sess.file_index->file_index.relations.end()) + continue; + auto file_path = workspace.path_pool.resolve(id); + llvm::StringRef content = sess.file_index->content; + + for(auto& rel: it->second) { + if(rel.kind != kind) + continue; + auto start = sess.file_index->mapper->to_position(rel.range.begin); + if(!start) + continue; + results.push_back(ReferenceWithContext{ + .file = file_path.str(), + .line = static_cast(start->line) + 1, + .context = extract_line(content, rel.range.begin), + }); + } + } + + return results; +} + std::vector Indexer::find_incoming_calls(index::SymbolHash hash) { llvm::DenseMap> caller_ranges; @@ -642,6 +788,11 @@ void Indexer::resume_indexing() { } } +kota::task<> Indexer::stop() { + bg_tasks.cancel(); + co_await bg_tasks.join(); +} + void Indexer::schedule() { if(!*workspace.config.project.enable_indexing || indexing_active || indexing_scheduled) return; @@ -651,7 +802,11 @@ void Indexer::schedule() { index_idle_timer = std::make_shared(kota::timer::create(loop)); } index_idle_timer->start(std::chrono::milliseconds(*workspace.config.project.idle_timeout_ms)); - loop.schedule(run_background_indexing()); + + if(!bg_tasks.spawn(run_background_indexing())) { + indexing_scheduled = false; + LOG_WARN("Failed to spawn background indexing task (task group stopped)"); + } } kota::task<> Indexer::index_one(std::uint32_t server_path_id) { @@ -734,81 +889,69 @@ kota::task<> Indexer::run_background_indexing() { indexing_active = true; kota::cancellation_source monitor_cancel; - kota::task_group<> index_group(loop); - index_group.spawn(kota::with_token(monitor_resources(), monitor_cancel.token())); + bg_tasks.spawn(kota::with_token(monitor_resources(), monitor_cancel.token())); std::stable_partition( index_queue.begin() + index_queue_pos, index_queue.end(), [this](std::uint32_t id) { return workspace.path_to_module.contains(id); }); - auto batch = index_queue.size() - index_queue_pos; - std::size_t inflight = 0; + auto total = index_queue.size() - index_queue_pos; std::size_t dispatched = 0; std::size_t completed = 0; - std::size_t finished = 0; - kota::event completion_event; std::optional> progress; if(peer) { progress.emplace(*peer, protocol::ProgressToken(std::string("clice/backgroundIndex"))); auto create_result = co_await progress->create(); if(!create_result.has_error()) { - progress->begin("Indexing", std::format("0/{} files", batch), 0); + progress->begin("Indexing", std::format("0/{} files", total), 0); } else { progress.reset(); } } - while(index_queue_pos < index_queue.size() || inflight > 0) { - while(index_queue_pos < index_queue.size() && inflight < max_concurrent) { - if(pause_depth > 0) { - co_await resume_event.wait(); - } + kota::task_group<> workers(loop); + std::size_t in_flight = 0; + kota::event slot_available; - auto server_path_id = index_queue[index_queue_pos++]; + while(index_queue_pos < index_queue.size()) { + if(pause_depth > 0) + co_await resume_event.wait(); - auto file_path = std::string(workspace.path_pool.resolve(server_path_id)); - if(sessions.contains(server_path_id) || !need_update(file_path)) { - ++completed; - continue; - } - - ++inflight; - ++dispatched; - - index_group.spawn([](Indexer* self, - std::uint32_t id, - std::size_t& inflight_ref, - std::size_t& finished_ref, - kota::event& done) -> kota::task<> { - co_await self->index_one(id); - --inflight_ref; - ++finished_ref; - done.set(); - }(this, server_path_id, inflight, finished, completion_event)); + auto server_path_id = index_queue[index_queue_pos++]; + auto file_path = std::string(workspace.path_pool.resolve(server_path_id)); + if(sessions.contains(server_path_id) || !need_update(file_path)) { + ++completed; + continue; } - if(inflight == 0) - break; - - co_await completion_event.wait(); - completion_event.reset(); - - completed += std::exchange(finished, 0); - - if(progress) { - auto pct = batch > 0 ? static_cast(completed * 100 / batch) : 100; - progress->report(std::format("{}/{} files", completed, batch), pct); + while(in_flight >= max_concurrent) { + slot_available.reset(); + co_await slot_available.wait(); } + + ++in_flight; + ++dispatched; + workers.spawn([&, server_path_id]() -> kota::task<> { + co_await index_one(server_path_id); + --in_flight; + ++completed; + if(progress) { + auto pct = total > 0 ? static_cast(completed * 100 / total) : 100; + progress->report(std::format("{}/{} files", completed, total), pct); + } + slot_available.set(); + }()); } + co_await workers.join(); + if(progress) { progress->end(std::format("Indexed {} files", dispatched)); } monitor_cancel.cancel(); - co_await index_group.join(); indexing_active = false; LOG_INFO("Background indexing complete: {} files dispatched", dispatched); diff --git a/src/server/compiler/indexer.h b/src/server/compiler/indexer.h index 1e76395b..d1779cb2 100644 --- a/src/server/compiler/indexer.h +++ b/src/server/compiler/indexer.h @@ -61,8 +61,8 @@ public: WorkerPool& pool, Compiler& compiler, std::function is_file_open = {}) : - loop(loop), workspace(workspace), sessions(sessions), pool(pool), compiler(compiler), - is_file_open(std::move(is_file_open)) {} + loop(loop), bg_tasks(loop), workspace(workspace), sessions(sessions), pool(pool), + compiler(compiler), is_file_open(std::move(is_file_open)) {} /// Set the LSP peer for progress reporting. Must be called before /// schedule() if progress notifications are desired. @@ -167,6 +167,43 @@ public: std::vector search_symbols(llvm::StringRef query, std::size_t max_results = 100); + struct DefinitionText { + std::string file; + int start_line; + int end_line; + std::string text; + }; + + /// Get full definition text for a symbol, using stored index ranges and content. + std::optional get_definition_text(index::SymbolHash hash); + + struct ReferenceWithContext { + std::string file; + int line; + std::string context; + }; + + /// Collect references (or definitions) with context lines from stored content. + std::vector collect_references(index::SymbolHash hash, RelationKind kind); + + /// Cancel background indexing and wait for all tasks to settle. + kota::task<> stop(); + + /// Whether background indexing is currently idle (no active or queued work). + bool is_idle() const { + return !indexing_active && index_queue_pos >= index_queue.size(); + } + + /// Number of files remaining in the indexing queue. + std::size_t pending_files() const { + return index_queue_pos < index_queue.size() ? index_queue.size() - index_queue_pos : 0; + } + + /// Total files that were enqueued in the current (or last) indexing round. + std::size_t total_queued() const { + return index_queue.size(); + } + /// Convert internal SymbolKind to LSP SymbolKind. static protocol::SymbolKind to_lsp_symbol_kind(SymbolKind kind); @@ -208,6 +245,7 @@ private: private: kota::event_loop& loop; + kota::task_group<> bg_tasks; Workspace& workspace; llvm::DenseMap& sessions; WorkerPool& pool; diff --git a/src/server/protocol/agentic.h b/src/server/protocol/agentic.h index b17fa968..90f32568 100644 --- a/src/server/protocol/agentic.h +++ b/src/server/protocol/agentic.h @@ -1,5 +1,7 @@ #pragma once +#include +#include #include #include @@ -17,6 +19,200 @@ struct CompileCommandResult { std::vector arguments; }; +struct FileInfo { + std::string path; + std::string kind; + std::optional module_name; +}; + +struct ProjectFilesParams { + std::optional filter; +}; + +struct ProjectFilesResult { + std::vector files; + int total = 0; +}; + +struct DepEntry { + std::string path; + int depth = 0; +}; + +struct FileDepsParams { + std::string path; + std::optional direction; + std::optional depth; +}; + +struct FileDepsResult { + std::string file; + std::vector includes; + std::vector includers; +}; + +struct ImpactAnalysisParams { + std::string path; +}; + +struct ImpactAnalysisResult { + std::vector direct_dependents; + std::vector transitive_dependents; + std::vector affected_modules; +}; + +struct SymbolEntry { + std::string name; + std::string kind; + std::string file; + int line = 0; + std::optional container; + std::uint64_t symbol_id = 0; +}; + +struct SymbolSearchParams { + std::string query; + std::optional> kind_filter; + std::optional max_results; +}; + +struct SymbolSearchResult { + std::vector symbols; +}; + +struct ReadSymbolParams { + std::optional name; + std::optional path; + std::optional line; + std::optional symbol_id; +}; + +struct ReadSymbolResult { + std::string name; + std::string kind; + std::string file; + int start_line = 0; + int end_line = 0; + std::string text; + std::optional signature; + std::uint64_t symbol_id = 0; +}; + +struct DocumentSymbolEntry { + std::string name; + std::string kind; + int start_line = 0; + int end_line = 0; + std::uint64_t symbol_id = 0; +}; + +struct DocumentSymbolsParams { + std::string path; +}; + +struct DocumentSymbolsResult { + std::vector symbols; +}; + +struct DefinitionParams { + std::optional name; + std::optional path; + std::optional line; + std::optional symbol_id; +}; + +struct LocationEntry { + std::string file; + int start_line = 0; + int end_line = 0; + std::string text; +}; + +struct DefinitionResult { + std::string name; + std::string kind; + std::uint64_t symbol_id = 0; + std::optional definition; +}; + +struct ReferenceEntry { + std::string file; + int line = 0; + std::string context; +}; + +struct ReferencesParams { + std::optional name; + std::optional path; + std::optional line; + std::optional symbol_id; + std::optional include_declaration; +}; + +struct ReferencesResult { + std::string name; + std::string kind; + std::uint64_t symbol_id = 0; + std::vector references; + int total = 0; +}; + +struct CallGraphEntry { + std::string name; + std::string kind; + std::string file; + int line = 0; + std::uint64_t symbol_id = 0; +}; + +struct CallGraphParams { + std::optional name; + std::optional path; + std::optional line; + std::optional symbol_id; + std::optional direction; + std::optional depth; +}; + +struct CallGraphResult { + CallGraphEntry root; + std::vector callers; + std::vector callees; +}; + +struct TypeHierarchyEntry { + std::string name; + std::string kind; + std::string file; + int line = 0; + std::uint64_t symbol_id = 0; +}; + +struct TypeHierarchyParams { + std::optional name; + std::optional path; + std::optional line; + std::optional symbol_id; + std::optional direction; +}; + +struct TypeHierarchyResult { + TypeHierarchyEntry root; + std::vector supertypes; + std::vector subtypes; +}; + +struct StatusParams {}; + +struct StatusResult { + bool idle = true; + int pending = 0; + int total = 0; + int indexed = 0; +}; + +struct ShutdownParams {}; + } // namespace clice::agentic namespace kota::ipc::protocol { @@ -27,4 +223,75 @@ struct RequestTraits { constexpr inline static std::string_view method = "agentic/compileCommand"; }; +template <> +struct RequestTraits { + using Result = clice::agentic::ProjectFilesResult; + constexpr inline static std::string_view method = "agentic/projectFiles"; +}; + +template <> +struct RequestTraits { + using Result = clice::agentic::FileDepsResult; + constexpr inline static std::string_view method = "agentic/fileDeps"; +}; + +template <> +struct RequestTraits { + using Result = clice::agentic::ImpactAnalysisResult; + constexpr inline static std::string_view method = "agentic/impactAnalysis"; +}; + +template <> +struct RequestTraits { + using Result = clice::agentic::SymbolSearchResult; + constexpr inline static std::string_view method = "agentic/symbolSearch"; +}; + +template <> +struct RequestTraits { + using Result = clice::agentic::ReadSymbolResult; + constexpr inline static std::string_view method = "agentic/readSymbol"; +}; + +template <> +struct RequestTraits { + using Result = clice::agentic::DocumentSymbolsResult; + constexpr inline static std::string_view method = "agentic/documentSymbols"; +}; + +template <> +struct RequestTraits { + using Result = clice::agentic::DefinitionResult; + constexpr inline static std::string_view method = "agentic/definition"; +}; + +template <> +struct RequestTraits { + using Result = clice::agentic::ReferencesResult; + constexpr inline static std::string_view method = "agentic/references"; +}; + +template <> +struct RequestTraits { + using Result = clice::agentic::CallGraphResult; + constexpr inline static std::string_view method = "agentic/callGraph"; +}; + +template <> +struct RequestTraits { + using Result = clice::agentic::TypeHierarchyResult; + constexpr inline static std::string_view method = "agentic/typeHierarchy"; +}; + +template <> +struct RequestTraits { + using Result = clice::agentic::StatusResult; + constexpr inline static std::string_view method = "agentic/status"; +}; + +template <> +struct NotificationTraits { + constexpr inline static std::string_view method = "agentic/shutdown"; +}; + } // namespace kota::ipc::protocol diff --git a/src/server/service/agent_client.cpp b/src/server/service/agent_client.cpp index bae366de..69c7a81b 100644 --- a/src/server/service/agent_client.cpp +++ b/src/server/service/agent_client.cpp @@ -1,27 +1,203 @@ #include "server/service/agent_client.h" +#include #include +#include #include #include #include "server/protocol/agentic.h" #include "server/service/master_server.h" +#include "support/filesystem.h" +#include "support/logging.h" + +#include "kota/ipc/lsp/uri.h" +#include "kota/meta/enum.h" +#include "llvm/ADT/DenseSet.h" +#include "llvm/ADT/SmallVector.h" namespace clice { using kota::ipc::RequestResult; using RequestContext = kota::ipc::JsonPeer::RequestContext; +namespace lsp = kota::ipc::lsp; +namespace protocol = kota::ipc::protocol; + +static std::string_view symbol_kind_name(SymbolKind kind) { + constexpr auto names = kota::meta::reflection::member_names; + auto idx = static_cast(kind.value()); + if(idx < names.size()) + return names[idx]; + return "Unknown"; +} + +struct ResolvedSymbol { + index::SymbolHash hash = 0; + std::string name; + SymbolKind kind; + std::string file; + int line = 0; +}; + +static std::vector resolve_locator(const agentic::ReadSymbolParams& loc, + Workspace& workspace, + llvm::DenseMap& sessions, + Indexer& indexer) { + if(loc.symbol_id.has_value() && *loc.symbol_id != 0) { + auto hash = static_cast(*loc.symbol_id); + std::string name; + SymbolKind kind; + if(!indexer.find_symbol_info(hash, name, kind)) + return {}; + auto def_loc = indexer.find_definition_location(hash); + if(!def_loc) + return {}; + auto file = uri_to_path(def_loc->uri); + int line_num = static_cast(def_loc->range.start.line) + 1; + return { + {hash, std::move(name), kind, std::move(file), line_num} + }; + } + + if(loc.name.has_value() && !loc.name->empty()) { + std::string query_lower = llvm::StringRef(*loc.name).lower(); + std::vector candidates; + std::vector exact_matches; + llvm::DenseSet seen; + + auto try_symbol = [&](index::SymbolHash hash, const index::Symbol& symbol) { + if(symbol.name.empty()) + return; + if(llvm::StringRef(symbol.name).lower().find(query_lower) == std::string::npos) + return; + auto def_loc = indexer.find_definition_location(hash); + if(!def_loc) + return; + if(!seen.insert(hash).second) + return; + + auto file = uri_to_path(def_loc->uri); + int line_num = static_cast(def_loc->range.start.line) + 1; + + if(loc.path.has_value() && !loc.path->empty()) { + llvm::StringRef wanted(*loc.path); + bool basename_only = wanted.find_last_of("/\\") == llvm::StringRef::npos; + if(basename_only) { + if(llvm::sys::path::filename(file) != wanted) + return; + } else if(!llvm::StringRef(file).ends_with(wanted)) { + return; + } + } + + bool is_exact = llvm::StringRef(symbol.name).lower() == query_lower || + llvm::StringRef(symbol.name).ends_with("::" + *loc.name); + + ResolvedSymbol rs{hash, symbol.name, symbol.kind, std::move(file), line_num}; + if(is_exact) + exact_matches.push_back(std::move(rs)); + else + candidates.push_back(std::move(rs)); + }; + + for(auto& [hash, symbol]: workspace.project_index.symbols) + try_symbol(hash, symbol); + for(auto& [_, sess]: sessions) { + if(!sess.file_index) + continue; + for(auto& [hash, symbol]: sess.file_index->symbols) + try_symbol(hash, symbol); + } + + if(!exact_matches.empty()) + return exact_matches; + return candidates; + } + + if(loc.path.has_value() && loc.line.has_value()) { + auto path_str = *loc.path; + auto target_line = static_cast(*loc.line - 1); + + auto pool_it = workspace.path_pool.cache.find(path_str); + auto server_id = pool_it != workspace.path_pool.cache.end() ? pool_it->second : ~0u; + auto* sess = + server_id != ~0u && sessions.contains(server_id) ? &sessions[server_id] : nullptr; + if(sess && sess->file_index) { + auto& fi = *sess->file_index; + if(fi.mapper) { + for(auto& [hash, rels]: fi.file_index.relations) { + for(auto& rel: rels) { + if(rel.kind.value() != RelationKind::Definition) + continue; + auto start = fi.mapper->to_position(rel.range.begin); + if(start && start->line == target_line) { + std::string name; + SymbolKind kind; + if(indexer.find_symbol_info(hash, name, kind)) + return { + {hash, std::move(name), kind, path_str, *loc.line} + }; + } + } + } + } + } + + auto it = workspace.project_index.path_pool.find(path_str); + if(it == workspace.project_index.path_pool.cache.end()) + return {}; + + auto proj_id = it->second; + auto shard_it = workspace.merged_indices.find(proj_id); + if(shard_it == workspace.merged_indices.end()) + return {}; + + for(auto& [hash, symbol]: workspace.project_index.symbols) { + if(!symbol.reference_files.contains(proj_id)) + continue; + bool found = false; + shard_it->second.find_relations(hash, + RelationKind::Definition, + [&](const index::Relation&, protocol::Range range) { + if(range.start.line == target_line) { + found = true; + return false; + } + return true; + }); + if(found) + return { + {hash, symbol.name, symbol.kind, path_str, *loc.line} + }; + } + + return {}; + } + + return {}; +} + +static std::uint64_t extract_symbol_id(const std::optional& data) { + if(!data.has_value()) + return 0; + if(auto* val = std::get_if(&static_cast(*data))) + return static_cast(*val); + LOG_WARN("extract_symbol_id: unexpected LSPAny variant type"); + return 0; +} AgentClient::AgentClient(MasterServer& server, kota::ipc::JsonPeer& peer) : server(server), peer(peer) { using namespace agentic; + auto& srv = this->server; + peer.on_request( - [this](RequestContext&, + [&srv](RequestContext&, const CompileCommandParams& params) -> RequestResult { std::string directory; std::vector arguments; - if(!this->server.compiler.fill_compile_args(params.path, directory, arguments)) { + if(!srv.compiler.fill_compile_args(params.path, directory, arguments)) { co_return kota::outcome_error( kota::ipc::Error{std::format("no compile command found for {}", params.path)}); } @@ -32,6 +208,580 @@ AgentClient::AgentClient(MasterServer& server, kota::ipc::JsonPeer& peer) : .arguments = std::move(arguments), }; }); + + peer.on_request([&srv](RequestContext&, + const ProjectFilesParams& params) -> RequestResult { + auto& ws = srv.workspace; + auto filter = params.filter.value_or("all"); + + ProjectFilesResult result; + llvm::DenseSet seen; + + for(auto& entry: ws.cdb.get_entries()) { + auto file_path = ws.cdb.resolve_path(entry.file); + if(file_path.empty()) + continue; + + auto proj_it = ws.project_index.path_pool.find(file_path); + if(proj_it != ws.project_index.path_pool.cache.end()) { + if(!seen.insert(proj_it->second).second) + continue; + } + + std::string kind_str; + auto mod_it = ws.path_to_module.find(ws.path_pool.intern(file_path)); + if(mod_it != ws.path_to_module.end()) { + kind_str = "module"; + } else { + auto ext = llvm::sys::path::extension(file_path); + if(ext == ".h" || ext == ".hpp" || ext == ".hxx" || ext == ".hh") + kind_str = "header"; + else + kind_str = "source"; + } + + if(filter != "all" && filter != kind_str) + continue; + + FileInfo fi; + fi.path = file_path.str(); + fi.kind = std::move(kind_str); + if(mod_it != ws.path_to_module.end()) + fi.module_name = mod_it->second; + result.files.push_back(std::move(fi)); + } + + if(filter == "all" || filter == "header") { + for(auto& [path_id, shard]: ws.merged_indices) { + if(seen.contains(path_id)) + continue; + auto path_str = ws.project_index.path_pool.path(path_id); + auto ext = llvm::sys::path::extension(path_str); + if(ext == ".h" || ext == ".hpp" || ext == ".hxx" || ext == ".hh") { + seen.insert(path_id); + result.files.push_back(FileInfo{ + .path = path_str.str(), + .kind = "header", + }); + } + } + } + + result.total = static_cast(result.files.size()); + co_return result; + }); + + peer.on_request( + [&srv](RequestContext&, const FileDepsParams& params) -> RequestResult { + auto& ws = srv.workspace; + auto pool_it = ws.path_pool.cache.find(params.path); + if(pool_it == ws.path_pool.cache.end()) + co_return FileDepsResult{.file = params.path}; + auto path_id = pool_it->second; + auto direction = params.direction.value_or("both"); + auto max_depth = params.depth.value_or(1); + + FileDepsResult result; + result.file = params.path; + + if(direction == "includes" || direction == "both") { + auto includes = ws.dep_graph.get_all_includes(path_id); + for(auto inc_id: includes) { + auto real_id = inc_id & DependencyGraph::PATH_ID_MASK; + auto inc_path = ws.path_pool.resolve(real_id); + result.includes.push_back(DepEntry{.path = inc_path.str(), .depth = 1}); + } + + if(max_depth == 0 || max_depth > 1) { + llvm::DenseSet visited; + visited.insert(path_id); + for(auto& dep: result.includes) + visited.insert(ws.path_pool.intern(dep.path)); + + for(std::size_t i = 0; i < result.includes.size(); ++i) { + if(max_depth > 0 && result.includes[i].depth >= max_depth) + continue; + auto dep_id = ws.path_pool.intern(result.includes[i].path); + auto sub = ws.dep_graph.get_all_includes(dep_id); + for(auto sub_id: sub) { + auto real_id = sub_id & DependencyGraph::PATH_ID_MASK; + if(!visited.insert(real_id).second) + continue; + auto sub_path = ws.path_pool.resolve(real_id); + result.includes.push_back(DepEntry{ + .path = sub_path.str(), + .depth = result.includes[i].depth + 1, + }); + } + } + } + } + + if(direction == "includers" || direction == "both") { + auto includers = ws.dep_graph.get_includers(path_id); + for(auto inc_id: includers) { + auto inc_path = ws.path_pool.resolve(inc_id); + result.includers.push_back(DepEntry{.path = inc_path.str(), .depth = 1}); + } + + if(max_depth == 0 || max_depth > 1) { + llvm::DenseSet visited; + visited.insert(path_id); + for(auto& dep: result.includers) { + auto it = ws.path_pool.cache.find(dep.path); + if(it != ws.path_pool.cache.end()) + visited.insert(it->second); + } + + for(std::size_t i = 0; i < result.includers.size(); ++i) { + if(max_depth > 0 && result.includers[i].depth >= max_depth) + continue; + auto dep_it = ws.path_pool.cache.find(result.includers[i].path); + if(dep_it == ws.path_pool.cache.end()) + continue; + auto sub = ws.dep_graph.get_includers(dep_it->second); + for(auto sub_id: sub) { + if(!visited.insert(sub_id).second) + continue; + auto sub_path = ws.path_pool.resolve(sub_id); + result.includers.push_back(DepEntry{ + .path = sub_path.str(), + .depth = result.includers[i].depth + 1, + }); + } + } + } + } + + co_return result; + }); + + peer.on_request( + [&srv](RequestContext&, + const ImpactAnalysisParams& params) -> RequestResult { + auto& ws = srv.workspace; + auto pool_it = ws.path_pool.cache.find(params.path); + if(pool_it == ws.path_pool.cache.end()) + co_return ImpactAnalysisResult{}; + auto path_id = pool_it->second; + + ImpactAnalysisResult result; + + auto direct_includers = ws.dep_graph.get_includers(path_id); + for(auto inc_id: direct_includers) { + result.direct_dependents.push_back(ws.path_pool.resolve(inc_id).str()); + } + + auto hosts = ws.dep_graph.find_host_sources(path_id); + llvm::DenseSet seen; + seen.insert(path_id); + for(auto inc_id: direct_includers) + seen.insert(inc_id); + for(auto host_id: hosts) { + if(seen.insert(host_id).second) + result.transitive_dependents.push_back(ws.path_pool.resolve(host_id).str()); + } + + for(auto host_id: hosts) { + auto it = ws.path_to_module.find(host_id); + if(it != ws.path_to_module.end()) + result.affected_modules.push_back(it->second); + } + auto mod_it = ws.path_to_module.find(path_id); + if(mod_it != ws.path_to_module.end()) + result.affected_modules.push_back(mod_it->second); + + co_return result; + }); + + peer.on_request([&srv](RequestContext&, + const SymbolSearchParams& params) -> RequestResult { + auto max = params.max_results.value_or(100); + std::string query_lower = llvm::StringRef(params.query).lower(); + + SymbolSearchResult result; + llvm::DenseSet seen; + + auto try_symbol = [&](index::SymbolHash hash, const index::Symbol& symbol) { + if(static_cast(result.symbols.size()) >= max) + return; + if(symbol.name.empty()) + return; + if(!query_lower.empty() && + llvm::StringRef(symbol.name).lower().find(query_lower) == std::string::npos) + return; + if(params.kind_filter.has_value()) { + auto kind_name = std::string(symbol_kind_name(symbol.kind)); + auto& filter = *params.kind_filter; + if(std::ranges::find(filter, kind_name) == filter.end()) + return; + } + auto def_loc = srv.indexer.find_definition_location(hash); + if(!def_loc) + return; + if(!seen.insert(hash).second) + return; + auto file = uri_to_path(def_loc->uri); + result.symbols.push_back(SymbolEntry{ + .name = symbol.name, + .kind = std::string(symbol_kind_name(symbol.kind)), + .file = std::move(file), + .line = static_cast(def_loc->range.start.line) + 1, + .symbol_id = hash, + }); + }; + + for(auto& [hash, symbol]: srv.workspace.project_index.symbols) + try_symbol(hash, symbol); + for(auto& [_, sess]: srv.sessions) { + if(!sess.file_index) + continue; + for(auto& [hash, symbol]: sess.file_index->symbols) + try_symbol(hash, symbol); + } + + co_return result; + }); + + peer.on_request( + [&srv](RequestContext&, const ReadSymbolParams& params) -> RequestResult { + auto candidates = resolve_locator(params, srv.workspace, srv.sessions, srv.indexer); + if(candidates.empty()) + co_return kota::outcome_error(kota::ipc::Error{"symbol not found"}); + if(candidates.size() > 1) { + co_return kota::outcome_error(kota::ipc::Error{ + std::format("ambiguous: {} candidates, use symbolId to disambiguate", + candidates.size())}); + } + + auto& rs = candidates[0]; + auto def_text = srv.indexer.get_definition_text(rs.hash); + if(!def_text) + co_return kota::outcome_error(kota::ipc::Error{"definition not found"}); + + co_return ReadSymbolResult{ + .name = rs.name, + .kind = std::string(symbol_kind_name(rs.kind)), + .file = std::move(def_text->file), + .start_line = def_text->start_line, + .end_line = def_text->end_line, + .text = std::move(def_text->text), + .symbol_id = rs.hash, + }; + }); + + peer.on_request( + [&srv](RequestContext&, + const DocumentSymbolsParams& params) -> RequestResult { + auto is_document_level = [](SymbolKind kind) { + return kind == SymbolKind::Namespace || kind == SymbolKind::Class || + kind == SymbolKind::Struct || kind == SymbolKind::Union || + kind == SymbolKind::Enum || kind == SymbolKind::Type || + kind == SymbolKind::Field || kind == SymbolKind::EnumMember || + kind == SymbolKind::Function || kind == SymbolKind::Method || + kind == SymbolKind::Variable || kind == SymbolKind::Macro || + kind == SymbolKind::Concept || kind == SymbolKind::Module || + kind == SymbolKind::Operator || kind == SymbolKind::Attribute; + }; + + DocumentSymbolsResult result; + + auto pool_it = srv.workspace.path_pool.cache.find(params.path); + if(pool_it == srv.workspace.path_pool.cache.end()) + co_return result; + auto server_id = pool_it->second; + auto sess_it = srv.sessions.find(server_id); + if(sess_it != srv.sessions.end() && sess_it->second.file_index) { + auto& fi = *sess_it->second.file_index; + for(auto& [hash, rels]: fi.file_index.relations) { + for(auto& rel: rels) { + if(rel.kind.value() != RelationKind::Definition) + continue; + std::string name; + SymbolKind kind; + if(!srv.indexer.find_symbol_info(hash, name, kind)) + continue; + if(!is_document_level(kind)) + continue; + if(fi.mapper) { + auto start = fi.mapper->to_position(rel.range.begin); + auto end = fi.mapper->to_position(rel.range.end); + if(start && end) { + result.symbols.push_back(DocumentSymbolEntry{ + .name = std::move(name), + .kind = std::string(symbol_kind_name(kind)), + .start_line = static_cast(start->line) + 1, + .end_line = static_cast(end->line) + 1, + .symbol_id = hash, + }); + break; + } + } + } + } + co_return result; + } + + auto it = srv.workspace.project_index.path_pool.find(params.path); + if(it == srv.workspace.project_index.path_pool.cache.end()) + co_return result; + + auto proj_id = it->second; + auto shard_it = srv.workspace.merged_indices.find(proj_id); + if(shard_it == srv.workspace.merged_indices.end()) + co_return result; + + for(auto& [hash, symbol]: srv.workspace.project_index.symbols) { + if(symbol.name.empty()) + continue; + if(!is_document_level(symbol.kind)) + continue; + if(!symbol.reference_files.contains(proj_id)) + continue; + + shard_it->second.find_relations( + hash, + RelationKind::Definition, + [&](const index::Relation&, protocol::Range range) { + result.symbols.push_back(DocumentSymbolEntry{ + .name = symbol.name, + .kind = std::string(symbol_kind_name(symbol.kind)), + .start_line = static_cast(range.start.line) + 1, + .end_line = static_cast(range.end.line) + 1, + .symbol_id = hash, + }); + return true; + }); + } + + co_return result; + }); + + peer.on_request( + [&srv](RequestContext&, const DefinitionParams& params) -> RequestResult { + auto candidates = resolve_locator( + ReadSymbolParams{params.name, params.path, params.line, params.symbol_id}, + srv.workspace, + srv.sessions, + srv.indexer); + if(candidates.empty()) + co_return kota::outcome_error(kota::ipc::Error{"symbol not found"}); + if(candidates.size() > 1) { + co_return kota::outcome_error(kota::ipc::Error{ + std::format("ambiguous: {} candidates, use symbolId to disambiguate", + candidates.size())}); + } + + auto& rs = candidates[0]; + + DefinitionResult result; + result.name = rs.name; + result.kind = std::string(symbol_kind_name(rs.kind)); + result.symbol_id = rs.hash; + + if(auto def_text = srv.indexer.get_definition_text(rs.hash)) { + result.definition = LocationEntry{ + .file = std::move(def_text->file), + .start_line = def_text->start_line, + .end_line = def_text->end_line, + .text = std::move(def_text->text), + }; + } + + co_return result; + }); + + peer.on_request( + [&srv](RequestContext&, const ReferencesParams& params) -> RequestResult { + auto candidates = resolve_locator( + ReadSymbolParams{params.name, params.path, params.line, params.symbol_id}, + srv.workspace, + srv.sessions, + srv.indexer); + if(candidates.empty()) + co_return kota::outcome_error(kota::ipc::Error{"symbol not found"}); + if(candidates.size() > 1) { + co_return kota::outcome_error(kota::ipc::Error{ + std::format("ambiguous: {} candidates, use symbolId to disambiguate", + candidates.size())}); + } + + auto& rs = candidates[0]; + + ReferencesResult result; + result.name = rs.name; + result.kind = std::string(symbol_kind_name(rs.kind)); + result.symbol_id = rs.hash; + + for(auto& ref: srv.indexer.collect_references(rs.hash, RelationKind::Reference)) { + result.references.push_back(ReferenceEntry{ + .file = std::move(ref.file), + .line = ref.line, + .context = std::move(ref.context), + }); + } + if(params.include_declaration.value_or(false)) { + for(auto& ref: srv.indexer.collect_references(rs.hash, RelationKind::Definition)) { + result.references.push_back(ReferenceEntry{ + .file = std::move(ref.file), + .line = ref.line, + .context = std::move(ref.context), + }); + } + } + + result.total = static_cast(result.references.size()); + co_return result; + }); + + peer.on_request( + [&srv](RequestContext&, const CallGraphParams& params) -> RequestResult { + auto candidates = resolve_locator( + ReadSymbolParams{params.name, params.path, params.line, params.symbol_id}, + srv.workspace, + srv.sessions, + srv.indexer); + if(candidates.empty()) + co_return kota::outcome_error(kota::ipc::Error{"symbol not found"}); + if(candidates.size() > 1) { + co_return kota::outcome_error(kota::ipc::Error{ + std::format("ambiguous: {} candidates, use symbolId to disambiguate", + candidates.size())}); + } + + auto& rs = candidates[0]; + auto direction = params.direction.value_or("both"); + + CallGraphResult result; + result.root = CallGraphEntry{ + .name = rs.name, + .kind = std::string(symbol_kind_name(rs.kind)), + .file = rs.file, + .line = rs.line, + .symbol_id = rs.hash, + }; + + auto resolve_kind = [&](std::uint64_t sym_id) -> std::string { + if(sym_id == 0) + return "Function"; + std::string name; + SymbolKind kind; + if(srv.indexer.find_symbol_info(sym_id, name, kind)) + return std::string(symbol_kind_name(kind)); + return "Function"; + }; + + if(direction == "callers" || direction == "both") { + auto incoming = srv.indexer.find_incoming_calls(rs.hash); + for(auto& call: incoming) { + auto sid = extract_symbol_id(call.from.data); + result.callers.push_back(CallGraphEntry{ + .name = call.from.name, + .kind = resolve_kind(sid), + .file = uri_to_path(call.from.uri), + .line = static_cast(call.from.range.start.line) + 1, + .symbol_id = sid, + }); + } + } + + if(direction == "callees" || direction == "both") { + auto outgoing = srv.indexer.find_outgoing_calls(rs.hash); + for(auto& call: outgoing) { + auto sid = extract_symbol_id(call.to.data); + result.callees.push_back(CallGraphEntry{ + .name = call.to.name, + .kind = resolve_kind(sid), + .file = uri_to_path(call.to.uri), + .line = static_cast(call.to.range.start.line) + 1, + .symbol_id = sid, + }); + } + } + + co_return result; + }); + + peer.on_request( + [&srv](RequestContext&, + const TypeHierarchyParams& params) -> RequestResult { + auto candidates = resolve_locator( + ReadSymbolParams{params.name, params.path, params.line, params.symbol_id}, + srv.workspace, + srv.sessions, + srv.indexer); + if(candidates.empty()) + co_return kota::outcome_error(kota::ipc::Error{"symbol not found"}); + if(candidates.size() > 1) { + co_return kota::outcome_error(kota::ipc::Error{ + std::format("ambiguous: {} candidates, use symbolId to disambiguate", + candidates.size())}); + } + + auto& rs = candidates[0]; + auto direction = params.direction.value_or("both"); + + TypeHierarchyResult result; + result.root = TypeHierarchyEntry{ + .name = rs.name, + .kind = std::string(symbol_kind_name(rs.kind)), + .file = rs.file, + .line = rs.line, + .symbol_id = rs.hash, + }; + + auto resolve_kind = [&](std::uint64_t sym_id) -> std::string { + if(sym_id == 0) + return "Class"; + std::string name; + SymbolKind kind; + if(srv.indexer.find_symbol_info(sym_id, name, kind)) + return std::string(symbol_kind_name(kind)); + return "Class"; + }; + + if(direction == "supertypes" || direction == "both") { + for(auto& item: srv.indexer.find_supertypes(rs.hash)) { + auto sid = extract_symbol_id(item.data); + result.supertypes.push_back(TypeHierarchyEntry{ + .name = item.name, + .kind = resolve_kind(sid), + .file = uri_to_path(item.uri), + .line = static_cast(item.range.start.line) + 1, + .symbol_id = sid, + }); + } + } + + if(direction == "subtypes" || direction == "both") { + for(auto& item: srv.indexer.find_subtypes(rs.hash)) { + auto sid = extract_symbol_id(item.data); + result.subtypes.push_back(TypeHierarchyEntry{ + .name = item.name, + .kind = resolve_kind(sid), + .file = uri_to_path(item.uri), + .line = static_cast(item.range.start.line) + 1, + .symbol_id = sid, + }); + } + } + + co_return result; + }); + + peer.on_request([&srv](RequestContext&, const StatusParams&) -> RequestResult { + StatusResult result; + result.idle = srv.indexer.is_idle(); + result.pending = static_cast(srv.indexer.pending_files()); + result.total = static_cast(srv.indexer.total_queued()); + result.indexed = std::max(0, result.total - result.pending); + co_return result; + }); + + peer.on_notification([&srv](const ShutdownParams&) { + LOG_INFO("agentic/shutdown received, shutting down"); + srv.schedule_shutdown(); + }); } } // namespace clice diff --git a/src/server/service/agentic.cpp b/src/server/service/agentic.cpp index 02fd72f6..584f13e1 100644 --- a/src/server/service/agentic.cpp +++ b/src/server/service/agentic.cpp @@ -5,6 +5,7 @@ #include #include "server/protocol/agentic.h" +#include "support/filesystem.h" #include "support/logging.h" #include "kota/async/async.h" @@ -13,45 +14,162 @@ namespace clice { -static kota::task<> agentic_request(kota::ipc::JsonPeer& peer, int& exit_code, std::string path) { - auto result = - co_await peer.send_request(agentic::CompileCommandParams{.path = std::move(path)}); - +template +static kota::task send_and_print(kota::ipc::JsonPeer& peer, Params params) { + auto result = co_await peer.send_request(std::move(params)); if(!result) { LOG_ERROR("request failed: {}", result.error().message); + co_return false; + } + auto json = kota::codec::json::to_string(*result); + std::println("{}", json ? *json : "null"); + co_return true; +} + +static kota::task<> agentic_request(kota::ipc::JsonPeer& peer, + int& exit_code, + const AgenticQueryOptions& opts) { + bool ok = false; + + if(opts.method == "compileCommand") { + ok = co_await send_and_print(peer, agentic::CompileCommandParams{.path = opts.path}); + } else if(opts.method == "projectFiles") { + auto filter = opts.query.empty() ? std::nullopt : std::optional(opts.query); + ok = co_await send_and_print(peer, agentic::ProjectFilesParams{.filter = filter}); + } else if(opts.method == "symbolSearch") { + ok = co_await send_and_print(peer, agentic::SymbolSearchParams{.query = opts.query}); + } else if(opts.method == "definition") { + auto name = opts.name.empty() ? std::nullopt : std::optional(opts.name); + auto path = opts.path.empty() ? std::nullopt : std::optional(opts.path); + auto line = opts.line > 0 ? std::optional(opts.line) : std::nullopt; + ok = co_await send_and_print( + peer, + agentic::DefinitionParams{.name = name, .path = path, .line = line}); + } else if(opts.method == "references") { + auto name = opts.name.empty() ? std::nullopt : std::optional(opts.name); + auto path = opts.path.empty() ? std::nullopt : std::optional(opts.path); + auto line = opts.line > 0 ? std::optional(opts.line) : std::nullopt; + ok = co_await send_and_print( + peer, + agentic::ReferencesParams{.name = name, .path = path, .line = line}); + } else if(opts.method == "readSymbol") { + auto name = opts.name.empty() ? std::nullopt : std::optional(opts.name); + auto path = opts.path.empty() ? std::nullopt : std::optional(opts.path); + auto line = opts.line > 0 ? std::optional(opts.line) : std::nullopt; + ok = co_await send_and_print( + peer, + agentic::ReadSymbolParams{.name = name, .path = path, .line = line}); + } else if(opts.method == "documentSymbols") { + ok = co_await send_and_print(peer, agentic::DocumentSymbolsParams{.path = opts.path}); + } else if(opts.method == "callGraph") { + auto name = opts.name.empty() ? std::nullopt : std::optional(opts.name); + auto path = opts.path.empty() ? std::nullopt : std::optional(opts.path); + auto line = opts.line > 0 ? std::optional(opts.line) : std::nullopt; + auto dir = opts.direction.empty() ? std::nullopt : std::optional(opts.direction); + ok = co_await send_and_print(peer, + agentic::CallGraphParams{ + .name = name, + .path = path, + .line = line, + .direction = dir, + }); + } else if(opts.method == "typeHierarchy") { + auto name = opts.name.empty() ? std::nullopt : std::optional(opts.name); + auto path = opts.path.empty() ? std::nullopt : std::optional(opts.path); + auto line = opts.line > 0 ? std::optional(opts.line) : std::nullopt; + auto dir = opts.direction.empty() ? std::nullopt : std::optional(opts.direction); + ok = co_await send_and_print(peer, + agentic::TypeHierarchyParams{ + .name = name, + .path = path, + .line = line, + .direction = dir, + }); + } else if(opts.method == "fileDeps") { + auto dir = opts.direction.empty() ? std::nullopt : std::optional(opts.direction); + ok = co_await send_and_print(peer, + agentic::FileDepsParams{.path = opts.path, .direction = dir}); + } else if(opts.method == "impactAnalysis") { + ok = co_await send_and_print(peer, agentic::ImpactAnalysisParams{.path = opts.path}); + } else if(opts.method == "status") { + ok = co_await send_and_print(peer, agentic::StatusParams{}); + } else if(opts.method == "shutdown") { + peer.send_notification(agentic::ShutdownParams{}); + ok = true; } else { - auto json = kota::codec::json::to_string(*result); - std::println("{}", json ? *json : "null"); - exit_code = 0; + LOG_ERROR("unknown agentic method '{}'", opts.method); } + if(ok) + exit_code = 0; peer.close(); } static kota::task<> agentic_client(int& exit_code, std::unique_ptr& peer_out, - std::string host, - int port, - std::string path) { + const AgenticQueryOptions& opts) { auto& loop = kota::event_loop::current(); - auto transport = co_await kota::ipc::StreamTransport::connect_tcp(host, port, loop); + auto transport = co_await kota::ipc::StreamTransport::connect_tcp(opts.host, opts.port, loop); if(!transport) { - LOG_ERROR("failed to connect to {}:{}", host, port); + LOG_ERROR("failed to connect to {}:{}", opts.host, opts.port); co_return; } peer_out = std::make_unique(loop, std::move(*transport)); - co_await kota::when_all(peer_out->run(), - agentic_request(*peer_out, exit_code, std::move(path))); + co_await kota::when_all(peer_out->run(), agentic_request(*peer_out, exit_code, opts)); } -int run_agentic_mode(llvm::StringRef host, int port, llvm::StringRef path) { +int run_agentic_mode(const AgenticQueryOptions& opts) { logging::stderr_logger("agentic", logging::options); kota::event_loop loop; int exit_code = 1; std::unique_ptr peer; - loop.schedule(agentic_client(exit_code, peer, host.str(), port, path.str())); + loop.schedule(agentic_client(exit_code, peer, opts)); + loop.run(); + return exit_code; +} + +static kota::task<> relay_forward(kota::ipc::Transport& from, kota::ipc::Transport& to) { + while(true) { + auto msg = co_await from.read_message(); + if(!msg) + break; + co_await to.write_message(*msg); + } + to.close(); +} + +static kota::task<> relay_main(kota::event_loop& loop, int& exit_code, std::string socket_path) { + auto stdio = kota::ipc::StreamTransport::open_stdio(loop); + if(!stdio) { + LOG_ERROR("failed to open stdio transport"); + loop.stop(); + co_return; + } + + auto conn = co_await kota::pipe::connect(socket_path, {}, loop); + if(!conn) { + LOG_ERROR("failed to connect to {}", socket_path); + loop.stop(); + co_return; + } + + auto socket = std::make_unique(std::move(*conn)); + + co_await kota::when_all(relay_forward(**stdio, *socket), relay_forward(*socket, **stdio)); + exit_code = 0; + loop.stop(); +} + +int run_relay_mode(llvm::StringRef socket_path) { + logging::stderr_logger("relay", logging::options); + + auto path = socket_path.empty() ? path::default_socket_path() : socket_path.str(); + + kota::event_loop loop; + int exit_code = 1; + loop.schedule(relay_main(loop, exit_code, std::move(path))); loop.run(); return exit_code; } diff --git a/src/server/service/agentic.h b/src/server/service/agentic.h index b2c625b4..ba59c051 100644 --- a/src/server/service/agentic.h +++ b/src/server/service/agentic.h @@ -6,6 +6,19 @@ namespace clice { -int run_agentic_mode(llvm::StringRef host, int port, llvm::StringRef path); +struct AgenticQueryOptions { + std::string host; + int port = 0; + std::string method; + std::string path; + std::string name; + std::string query; + int line = 0; + std::string direction; +}; + +int run_agentic_mode(const AgenticQueryOptions& opts); + +int run_relay_mode(llvm::StringRef socket_path); } // namespace clice diff --git a/src/server/service/lsp_client.cpp b/src/server/service/lsp_client.cpp index d71e4144..d6fcf7c1 100644 --- a/src/server/service/lsp_client.cpp +++ b/src/server/service/lsp_client.cpp @@ -152,10 +152,8 @@ LSPClient::LSPClient(MasterServer& server, kota::ipc::JsonPeer& peer) : server(s }); peer.on_notification([this]([[maybe_unused]] const protocol::ExitParams& params) { - auto& srv = this->server; - srv.lifecycle = ServerLifecycle::Exited; LOG_INFO("Exit notification received"); - srv.schedule_shutdown(); + this->server.schedule_shutdown(); }); peer.on_notification([this](const protocol::DidOpenTextDocumentParams& params) { diff --git a/src/server/service/master_server.cpp b/src/server/service/master_server.cpp index 9e6fbbc9..9e8b9010 100644 --- a/src/server/service/master_server.cpp +++ b/src/server/service/master_server.cpp @@ -1,10 +1,18 @@ #include "server/service/master_server.h" +#include +#include #include #include #include #include +#ifndef _WIN32 +#include +#include +#include +#endif + #include "server/protocol/worker.h" #include "server/service/agent_client.h" #include "server/service/lsp_client.h" @@ -12,6 +20,7 @@ #include "support/logging.h" #include "kota/async/async.h" +#include "kota/async/io/fs_event.h" #include "kota/codec/json/json.h" #include "kota/ipc/codec/json.h" #include "kota/ipc/lsp/protocol.h" @@ -19,6 +28,7 @@ #include "kota/ipc/recording_transport.h" #include "kota/ipc/transport.h" #include "llvm/Support/FileSystem.h" +#include "llvm/Support/Path.h" #include "llvm/Support/Process.h" namespace clice { @@ -91,6 +101,52 @@ void MasterServer::initialize() { load_workspace(); } +void MasterServer::initialize(llvm::StringRef root) { + workspace_root = root.str(); + initialize(); +} + +void MasterServer::start_file_watcher() { + if(workspace_root.empty()) + return; + + loop.schedule([this]() -> kota::task<> { + auto watcher = kota::fs_event::create(workspace_root, {}, loop); + if(!watcher) { + LOG_WARN("Failed to start file watcher for {}", workspace_root); + co_return; + } + + LOG_INFO("File watcher started for {}", workspace_root); + + while(true) { + auto changes = co_await watcher->next(); + if(!changes) + break; + + for(auto& change: *changes) { + if(change.type != kota::fs_event::effect::modify && + change.type != kota::fs_event::effect::create) + continue; + + llvm::StringRef file(change.path); + if(file.ends_with("compile_commands.json")) { + LOG_INFO("CDB changed, reloading workspace"); + load_workspace(); + continue; + } + + if(file.ends_with(".cpp") || file.ends_with(".cc") || file.ends_with(".cxx") || + file.ends_with(".c") || file.ends_with(".h") || file.ends_with(".hpp") || + file.ends_with(".hxx") || file.ends_with(".cppm") || file.ends_with(".ixx")) { + auto path_id = workspace.path_pool.intern(file); + on_file_saved(path_id); + } + } + } + }()); +} + Session* MasterServer::find_session(std::uint32_t path_id) { auto it = sessions.find(path_id); return it != sessions.end() ? &it->second : nullptr; @@ -148,12 +204,16 @@ void MasterServer::on_file_saved(std::uint32_t path_id) { } void MasterServer::schedule_shutdown() { + if(lifecycle == ServerLifecycle::Exited) + return; + lifecycle = ServerLifecycle::Exited; + indexer.save(workspace.config.project.index_dir); workspace.save_cache(); + shutdown_event.set(); loop.schedule([this]() -> kota::task<> { - co_await compiler.stop(); - co_await pool.stop(); + co_await kota::when_all(indexer.stop(), compiler.stop(), pool.stop()); loop.stop(); }()); } @@ -384,4 +444,108 @@ int run_server_mode(const ServerOptions& opts) { return 1; } +struct DaemonConnection { + std::unique_ptr peer; + std::unique_ptr agent_client; +}; + +static kota::task<> run_daemon_connection(kota::ipc::JsonPeer* peer, + std::list& connections, + std::list::iterator pos) { + co_await peer->run(); + LOG_INFO("Daemon client disconnected"); + connections.erase(pos); +} + +static kota::task<> daemon_main(MasterServer& server, kota::pipe::acceptor acceptor) { + auto& loop = kota::event_loop::current(); + std::list connections; + kota::task_group<> connection_group(loop); + + co_await kota::when_all( + [&]() -> kota::task<> { + while(true) { + auto conn = co_await acceptor.accept(); + if(!conn.has_value()) + break; + + LOG_INFO("Daemon client connected"); + + auto transport = std::make_unique(std::move(*conn)); + auto peer = std::make_unique(loop, std::move(transport)); + auto agent = std::make_unique(server, *peer); + + auto* peer_ptr = peer.get(); + auto it = connections.emplace(connections.end(), + DaemonConnection{ + .peer = std::move(peer), + .agent_client = std::move(agent), + }); + + connection_group.spawn(run_daemon_connection(peer_ptr, connections, it)); + } + }(), + [&]() -> kota::task<> { + co_await server.get_shutdown_event().wait(); + acceptor.stop(); + for(auto& conn: connections) { + conn.peer->close(); + } + }()); + + co_await connection_group.join(); +} + +int run_daemon_mode(const DaemonOptions& opts) { + logging::stderr_logger("daemon", logging::options); + + auto socket_path = opts.socket_path.empty() ? path::default_socket_path() : opts.socket_path; + + auto socket_dir = llvm::sys::path::parent_path(socket_path); + if(auto ec = llvm::sys::fs::create_directories(socket_dir)) { + LOG_ERROR("Failed to create socket directory {}: {}", socket_dir, ec.message()); + return 1; + } + + if(llvm::sys::fs::exists(socket_path)) { +#ifndef _WIN32 + int fd = ::socket(AF_UNIX, SOCK_STREAM, 0); + if(fd >= 0) { + struct sockaddr_un addr{}; + addr.sun_family = AF_UNIX; + auto len = std::min(socket_path.size(), sizeof(addr.sun_path) - 1); + std::memcpy(addr.sun_path, socket_path.data(), len); + bool live = ::connect(fd, reinterpret_cast(&addr), sizeof(addr)) == 0; + ::close(fd); + if(live) { + LOG_ERROR("Another daemon is already running on {}", socket_path); + return 1; + } + } +#endif + llvm::sys::fs::remove(socket_path); + } + + kota::event_loop loop; + MasterServer server(loop, opts.self_path); + + if(!opts.workspace.empty()) { + server.initialize(opts.workspace); + server.start_file_watcher(); + } + + auto acceptor = kota::pipe::listen(socket_path, {}, loop); + if(!acceptor) { + LOG_ERROR("Failed to listen on {}", socket_path); + return 1; + } + + LOG_INFO("Daemon listening on {}", socket_path); + loop.schedule(daemon_main(server, std::move(*acceptor))); + loop.run(); + + llvm::sys::fs::remove(socket_path); + return 0; +} + } // namespace clice diff --git a/src/server/service/master_server.h b/src/server/service/master_server.h index 4e7566ee..2a9f3793 100644 --- a/src/server/service/master_server.h +++ b/src/server/service/master_server.h @@ -37,6 +37,9 @@ public: ~MasterServer(); void initialize(); + void initialize(llvm::StringRef root); + + void start_file_watcher(); Session* find_session(std::uint32_t path_id); Session& open_session(std::uint32_t path_id); @@ -46,7 +49,12 @@ public: void schedule_shutdown(); + kota::event& get_shutdown_event() { + return shutdown_event; + } + private: + kota::event shutdown_event; void load_workspace(); kota::event_loop& loop; @@ -74,4 +82,12 @@ struct ServerOptions { int run_server_mode(const ServerOptions& opts); +struct DaemonOptions { + std::string socket_path; + std::string workspace; + std::string self_path; +}; + +int run_daemon_mode(const DaemonOptions& opts); + } // namespace clice diff --git a/src/server/worker/worker_pool.cpp b/src/server/worker/worker_pool.cpp index fccd1af8..2253e1b8 100644 --- a/src/server/worker/worker_pool.cpp +++ b/src/server/worker/worker_pool.cpp @@ -96,9 +96,8 @@ bool WorkerPool::spawn_worker(const std::string& self_path, std::move(spawn.stdin_pipe)); auto peer = std::make_unique(loop, std::move(transport)); - // Schedule stderr log collection std::string prefix = "[" + worker_name + "]"; - loop.schedule(drain_stderr(std::move(spawn.stderr_pipe), prefix)); + io_group.spawn(drain_stderr(std::move(spawn.stderr_pipe), prefix)); workers.push_back(WorkerProcess{ .proc = std::move(spawn.proc), @@ -108,7 +107,7 @@ bool WorkerPool::spawn_worker(const std::string& self_path, auto& w = workers.back(); w.alive = true; - loop.schedule(w.peer->run()); + io_group.spawn(w.peer->run()); return true; } @@ -160,7 +159,7 @@ kota::task<> WorkerPool::stop() { for(auto& w: stateful_workers) w.proc.kill(SIGTERM); - co_await monitor_group.join(); + co_await kota::when_all(monitor_group.join(), io_group.join()); LOG_INFO("WorkerPool stopped"); } @@ -320,7 +319,7 @@ bool WorkerPool::respawn_worker(std::size_t index, bool stateful) { auto peer = std::make_unique(loop, std::move(transport)); std::string prefix = "[" + worker_name + "]"; - loop.schedule(drain_stderr(std::move(spawn.stderr_pipe), prefix)); + io_group.spawn(drain_stderr(std::move(spawn.stderr_pipe), prefix)); workers[index] = WorkerProcess{ .proc = std::move(spawn.proc), @@ -331,7 +330,7 @@ bool WorkerPool::respawn_worker(std::size_t index, bool stateful) { }; auto& w = workers[index]; - loop.schedule(w.peer->run()); + io_group.spawn(w.peer->run()); if(stateful) { w.peer->on_notification([this](const worker::EvictedParams& params) { diff --git a/src/server/worker/worker_pool.h b/src/server/worker/worker_pool.h index c6948989..e8f30055 100644 --- a/src/server/worker/worker_pool.h +++ b/src/server/worker/worker_pool.h @@ -84,6 +84,7 @@ private: bool shutting_down_ = false; kota::task_group<> monitor_group{loop}; + kota::task_group<> io_group{loop}; WorkerPoolOptions options_; std::string log_dir_; diff --git a/src/support/filesystem.h b/src/support/filesystem.h index 23acdd19..4a99d67b 100644 --- a/src/support/filesystem.h +++ b/src/support/filesystem.h @@ -37,6 +37,14 @@ inline std::string real_path(llvm::StringRef file) { return path.str().str(); } +inline std::string default_socket_path() { + llvm::SmallString<128> home; + if(!llvm::sys::path::home_directory(home)) + return "/tmp/clice.sock"; + llvm::sys::path::append(home, ".clice", "clice.sock"); + return home.str().str(); +} + } // namespace path namespace fs { diff --git a/tests/integration/agentic/test_agentic.py b/tests/integration/agentic/test_agentic.py index 3eff6c07..549647a4 100644 --- a/tests/integration/agentic/test_agentic.py +++ b/tests/integration/agentic/test_agentic.py @@ -1,5 +1,6 @@ -"""Tests for the agentic CLI client.""" +"""Tests for the agentic protocol handlers.""" +import asyncio import json import socket import subprocess @@ -7,6 +8,60 @@ from concurrent.futures import ThreadPoolExecutor import pytest +from tests.integration.utils.wait import wait_for_index + + +class AgenticRpcClient: + """Minimal JSON-RPC client that speaks Content-Length framing over TCP.""" + + def __init__(self, host: str, port: int): + self.sock = socket.create_connection((host, port), timeout=10) + self.request_id = 0 + self.buffer = b"" + + def request(self, method: str, params: dict): + self.request_id += 1 + body = json.dumps( + { + "jsonrpc": "2.0", + "id": self.request_id, + "method": method, + "params": params, + } + ) + payload = f"Content-Length: {len(body)}\r\n\r\n{body}".encode("utf-8") + self.sock.sendall(payload) + return self._read_response() + + def _read_response(self): + while b"\r\n\r\n" not in self.buffer: + data = self.sock.recv(4096) + if not data: + raise ConnectionError("connection closed") + self.buffer += data + + header_end = self.buffer.index(b"\r\n\r\n") + headers = self.buffer[:header_end].decode("utf-8") + self.buffer = self.buffer[header_end + 4 :] + + content_length = 0 + for line in headers.split("\r\n"): + if line.lower().startswith("content-length:"): + content_length = int(line.split(":")[1].strip()) + + while len(self.buffer) < content_length: + data = self.sock.recv(4096) + if not data: + raise ConnectionError("connection closed") + self.buffer += data + + body = self.buffer[:content_length].decode("utf-8") + self.buffer = self.buffer[content_length:] + return json.loads(body) + + def close(self): + self.sock.close() + def run_agentic(executable, host, port, path, timeout=10): result = subprocess.run( @@ -61,7 +116,6 @@ async def test_multiple_requests(agentic, workspace): async def test_connection_refused(executable): - """Connecting to a port with no server should fail with non-zero exit.""" with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind(("127.0.0.1", 0)) free_port = s.getsockname()[1] @@ -71,7 +125,6 @@ async def test_connection_refused(executable): @pytest.mark.workspace("hello_world") async def test_concurrent_connections(agentic, workspace): - """Multiple agentic clients connecting simultaneously should all succeed.""" executable, host, port = agentic main_cpp = (workspace / "main.cpp").as_posix() @@ -85,3 +138,455 @@ async def test_concurrent_connections(agentic, workspace): assert r.returncode == 0, f"stderr: {r.stderr}" data = json.loads(r.stdout) assert data["file"] == main_cpp + + +@pytest.fixture +async def indexed_agentic(request, executable, workspace): + """Start server with LSP+agentic, compile a file, wait for indexing.""" + from tests.integration.utils.client import CliceClient + from tests.conftest import _shutdown_client, _find_free_port + + host = "127.0.0.1" + port = _find_free_port() + cmd = [str(executable), "--mode", "pipe", "--host", host, "--port", str(port)] + + c = CliceClient() + await c.start_io(*cmd) + + init_options = {"project": {"cache_dir": str(workspace / ".clice")}} + await c.initialize(workspace, initialization_options=init_options) + + uri, _ = await c.open_and_wait(workspace / "main.cpp") + assert await wait_for_index(c, uri, "add"), "Index not ready" + + rpc = AgenticRpcClient(host, port) + + for _ in range(30): + resp = rpc.request("agentic/symbolSearch", {"query": "add"}) + if "result" in resp and resp["result"]["symbols"]: + break + await asyncio.sleep(1) + else: + pytest.fail("agentic/symbolSearch never returned indexed symbols") + + yield rpc, workspace + + rpc.close() + c.close(uri) + await _shutdown_client(c) + + +@pytest.mark.workspace("index_features") +async def test_rpc_compile_command(indexed_agentic, workspace): + rpc, _ = indexed_agentic + path = (workspace / "main.cpp").as_posix() + resp = rpc.request("agentic/compileCommand", {"path": path}) + assert "result" in resp, f"unexpected response: {resp}" + result = resp["result"] + assert result["file"] == path + assert len(result["arguments"]) > 0 + + +@pytest.mark.workspace("index_features") +async def test_rpc_project_files(indexed_agentic, workspace): + rpc, _ = indexed_agentic + resp = rpc.request("agentic/projectFiles", {}) + assert "result" in resp, f"unexpected response: {resp}" + result = resp["result"] + assert result["total"] > 0 + paths = [f["path"] for f in result["files"]] + assert any("main.cpp" in p for p in paths) + + +@pytest.mark.workspace("index_features") +async def test_rpc_project_files_filter(indexed_agentic, workspace): + rpc, _ = indexed_agentic + resp = rpc.request("agentic/projectFiles", {"filter": "source"}) + assert "result" in resp + for f in resp["result"]["files"]: + assert f["kind"] == "source" + + +@pytest.mark.workspace("index_features") +async def test_rpc_symbol_search(indexed_agentic, workspace): + rpc, _ = indexed_agentic + resp = rpc.request("agentic/symbolSearch", {"query": "add"}) + assert "result" in resp, f"unexpected response: {resp}" + symbols = resp["result"]["symbols"] + add_sym = next((s for s in symbols if s["name"] == "add"), None) + assert add_sym is not None, f"'add' not found in {[s['name'] for s in symbols]}" + assert add_sym["kind"] == "Function" + assert add_sym["line"] == 19 + assert add_sym["symbolId"] != 0 + assert "main.cpp" in add_sym["file"] + + +@pytest.mark.workspace("index_features") +async def test_rpc_symbol_search_kind(indexed_agentic, workspace): + rpc, _ = indexed_agentic + resp = rpc.request( + "agentic/symbolSearch", {"query": "Animal", "kindFilter": ["Struct"]} + ) + assert "result" in resp + for s in resp["result"]["symbols"]: + assert s["kind"] == "Struct" + + +@pytest.mark.workspace("index_features") +async def test_rpc_symbol_search_max(indexed_agentic, workspace): + rpc, _ = indexed_agentic + resp = rpc.request("agentic/symbolSearch", {"query": "", "maxResults": 3}) + assert "result" in resp + assert len(resp["result"]["symbols"]) <= 3 + + +@pytest.mark.workspace("index_features") +async def test_rpc_read_symbol(indexed_agentic, workspace): + rpc, _ = indexed_agentic + resp = rpc.request("agentic/readSymbol", {"name": "add"}) + assert "result" in resp, f"unexpected response: {resp}" + result = resp["result"] + assert result["name"] == "add" + assert result["symbolId"] != 0 + assert result["startLine"] == 19 + assert result["endLine"] == 21 + assert "int add(int a, int b)" in result["text"] + assert "return a + b;" in result["text"] + + +@pytest.mark.workspace("index_features") +async def test_rpc_read_symbol_by_id(indexed_agentic, workspace): + rpc, _ = indexed_agentic + resp1 = rpc.request("agentic/readSymbol", {"name": "add"}) + assert "result" in resp1 + sid = resp1["result"]["symbolId"] + + resp2 = rpc.request("agentic/readSymbol", {"symbolId": sid}) + assert "result" in resp2 + assert resp2["result"]["name"] == "add" + assert resp2["result"]["symbolId"] == sid + + +@pytest.mark.workspace("index_features") +async def test_rpc_document_symbols(indexed_agentic, workspace): + rpc, _ = indexed_agentic + path = (workspace / "main.cpp").as_posix() + resp = rpc.request("agentic/documentSymbols", {"path": path}) + assert "result" in resp, f"unexpected response: {resp}" + symbols = resp["result"]["symbols"] + names = [s["name"] for s in symbols] + kinds = [s["kind"] for s in symbols] + assert "add" in names, f"expected 'add' in {names}" + assert "main" in names, f"expected 'main' in {names}" + assert "global_var" in names, f"expected 'global_var' in {names}" + assert "Parameter" not in kinds, ( + f"Parameters should be filtered: {list(zip(names, kinds))}" + ) + + +@pytest.mark.workspace("index_features") +async def test_rpc_definition(indexed_agentic, workspace): + rpc, _ = indexed_agentic + resp = rpc.request("agentic/definition", {"name": "add"}) + assert "result" in resp, f"unexpected response: {resp}" + result = resp["result"] + assert result["name"] == "add" + assert result["definition"] is not None + defn = result["definition"] + assert "main.cpp" in defn["file"] + assert defn["startLine"] == 19 + assert defn["endLine"] == 21 + assert "int add(int a, int b)" in defn["text"] + assert "return a + b;" in defn["text"] + + +@pytest.mark.workspace("index_features") +async def test_rpc_definition_by_position(indexed_agentic, workspace): + rpc, _ = indexed_agentic + path = (workspace / "main.cpp").as_posix() + resp = rpc.request("agentic/definition", {"path": path, "line": 19}) + assert "result" in resp, f"unexpected response: {resp}" + assert resp["result"]["name"] == "add" + + +@pytest.mark.workspace("index_features") +async def test_rpc_references(indexed_agentic, workspace): + rpc, _ = indexed_agentic + resp = rpc.request("agentic/references", {"name": "global_var"}) + assert "result" in resp, f"unexpected response: {resp}" + result = resp["result"] + assert result["name"] == "global_var" + assert result["total"] == 2 + lines = sorted(r["line"] for r in result["references"]) + assert lines == [34, 38] + contexts = [r["context"] for r in result["references"]] + assert any("global_var + 1" in c for c in contexts) + assert any("global_var * 2" in c for c in contexts) + + +@pytest.mark.workspace("index_features") +async def test_rpc_references_include_decl(indexed_agentic, workspace): + rpc, _ = indexed_agentic + resp = rpc.request( + "agentic/references", {"name": "global_var", "includeDeclaration": True} + ) + assert "result" in resp + result = resp["result"] + assert result["total"] == 3 + lines = sorted(r["line"] for r in result["references"]) + assert 31 in lines, f"expected declaration line 31 in {lines}" + + +@pytest.mark.workspace("index_features") +async def test_rpc_call_graph_incoming(indexed_agentic, workspace): + rpc, _ = indexed_agentic + resp = rpc.request("agentic/callGraph", {"name": "add", "direction": "callers"}) + assert "result" in resp, f"unexpected response: {resp}" + result = resp["result"] + assert result["root"]["name"] == "add" + assert result["root"]["line"] == 19 + assert result["root"]["symbolId"] != 0 + callers = result["callers"] + caller_names = [c["name"] for c in callers] + assert "compute" in caller_names, f"expected 'compute' in {caller_names}" + compute = next(c for c in callers if c["name"] == "compute") + assert compute["line"] == 24 + assert compute["symbolId"] != 0 + assert result["callees"] == [] + + +@pytest.mark.workspace("index_features") +async def test_rpc_call_graph_outgoing(indexed_agentic, workspace): + rpc, _ = indexed_agentic + resp = rpc.request("agentic/callGraph", {"name": "compute", "direction": "callees"}) + assert "result" in resp, f"unexpected response: {resp}" + result = resp["result"] + assert result["root"]["name"] == "compute" + callees = result["callees"] + callee_names = [c["name"] for c in callees] + assert "add" in callee_names, f"expected 'add' in {callee_names}" + add_entry = next(c for c in callees if c["name"] == "add") + assert add_entry["line"] == 19 + assert result["callers"] == [] + + +@pytest.mark.workspace("index_features") +async def test_rpc_type_hierarchy_supertypes(indexed_agentic, workspace): + rpc, _ = indexed_agentic + resp = rpc.request( + "agentic/typeHierarchy", {"name": "Dog", "direction": "supertypes"} + ) + assert "result" in resp, f"unexpected response: {resp}" + result = resp["result"] + assert result["root"]["name"] == "Dog" + assert result["root"]["line"] == 9 + supertypes = result["supertypes"] + supertype_names = [t["name"] for t in supertypes] + assert "Animal" in supertype_names, f"expected 'Animal' in {supertype_names}" + animal = next(t for t in supertypes if t["name"] == "Animal") + assert animal["line"] == 2 + assert animal["symbolId"] != 0 + + +@pytest.mark.workspace("index_features") +async def test_rpc_type_hierarchy_subtypes(indexed_agentic, workspace): + rpc, _ = indexed_agentic + resp = rpc.request( + "agentic/typeHierarchy", {"name": "Animal", "direction": "subtypes"} + ) + assert "result" in resp, f"unexpected response: {resp}" + result = resp["result"] + assert result["root"]["name"] == "Animal" + assert result["root"]["line"] == 2 + subtypes = result["subtypes"] + subtype_names = [t["name"] for t in subtypes] + assert "Dog" in subtype_names, f"expected 'Dog' in {subtype_names}" + assert "Cat" in subtype_names, f"expected 'Cat' in {subtype_names}" + dog = next(t for t in subtypes if t["name"] == "Dog") + assert dog["line"] == 9 + cat = next(t for t in subtypes if t["name"] == "Cat") + assert cat["line"] == 14 + + +@pytest.mark.workspace("index_features") +async def test_rpc_status(indexed_agentic, workspace): + rpc, _ = indexed_agentic + resp = rpc.request("agentic/status", {}) + assert "result" in resp, f"unexpected response: {resp}" + result = resp["result"] + assert isinstance(result["idle"], bool) + assert result["total"] > 0 + assert isinstance(result["pending"], int) + assert isinstance(result["indexed"], int) + + +@pytest.mark.workspace("hello_world") +async def test_rpc_shutdown(executable, workspace): + """Shutdown notification should cause the server to exit.""" + from tests.integration.utils.client import CliceClient + from tests.conftest import _shutdown_client, _find_free_port + + host = "127.0.0.1" + port = _find_free_port() + cmd = [str(executable), "--mode", "pipe", "--host", host, "--port", str(port)] + + c = CliceClient() + await c.start_io(*cmd) + init_options = {"project": {"cache_dir": str(workspace / ".clice")}} + await c.initialize(workspace, initialization_options=init_options) + + rpc = AgenticRpcClient(host, port) + body = json.dumps({"jsonrpc": "2.0", "method": "agentic/shutdown", "params": {}}) + rpc.sock.sendall(f"Content-Length: {len(body)}\r\n\r\n{body}".encode()) + rpc.sock.settimeout(5) + try: + rpc.sock.recv(4096) + except (socket.timeout, OSError): + pass + rpc.sock.close() + + import asyncio + + for _ in range(20): + if c._server.returncode is not None: + break + await asyncio.sleep(0.5) + assert c._server.returncode is not None, "Server did not exit after shutdown" + + +@pytest.mark.workspace("index_features") +async def test_rpc_symbol_not_found(indexed_agentic, workspace): + rpc, _ = indexed_agentic + resp = rpc.request("agentic/definition", {"name": "nonexistent_symbol_xyz"}) + assert "error" in resp + + +@pytest.mark.workspace("index_features") +async def test_rpc_symbol_id_roundtrip(indexed_agentic, workspace): + """Search -> get symbolId -> definition -> verify consistency.""" + rpc, _ = indexed_agentic + search = rpc.request("agentic/symbolSearch", {"query": "compute"}) + assert "result" in search + symbols = search["result"]["symbols"] + compute = next((s for s in symbols if s["name"] == "compute"), None) + assert compute is not None, f"'compute' not found in {[s['name'] for s in symbols]}" + + defn = rpc.request("agentic/definition", {"symbolId": compute["symbolId"]}) + assert "result" in defn + assert defn["result"]["name"] == "compute" + assert defn["result"]["symbolId"] == compute["symbolId"] + + +@pytest.mark.workspace("index_features") +async def test_rpc_file_deps(indexed_agentic, workspace): + rpc, _ = indexed_agentic + path = (workspace / "main.cpp").as_posix() + resp = rpc.request("agentic/fileDeps", {"path": path}) + assert "result" in resp, f"unexpected response: {resp}" + result = resp["result"] + assert result["file"] == path + assert isinstance(result["includes"], list) + assert isinstance(result["includers"], list) + + +@pytest.mark.workspace("index_features") +async def test_rpc_file_deps_direction(indexed_agentic, workspace): + rpc, _ = indexed_agentic + path = (workspace / "main.cpp").as_posix() + resp = rpc.request("agentic/fileDeps", {"path": path, "direction": "includes"}) + assert "result" in resp + assert resp["result"]["includers"] == [] + + +@pytest.mark.workspace("index_features") +async def test_rpc_file_deps_unknown(indexed_agentic, workspace): + rpc, _ = indexed_agentic + resp = rpc.request("agentic/fileDeps", {"path": "/nonexistent/file.cpp"}) + assert "result" in resp + assert resp["result"]["includes"] == [] + assert resp["result"]["includers"] == [] + + +@pytest.mark.workspace("index_features") +async def test_rpc_impact_analysis(indexed_agentic, workspace): + rpc, _ = indexed_agentic + path = (workspace / "main.cpp").as_posix() + resp = rpc.request("agentic/impactAnalysis", {"path": path}) + assert "result" in resp, f"unexpected response: {resp}" + result = resp["result"] + assert isinstance(result["directDependents"], list) + assert isinstance(result["transitiveDependents"], list) + assert isinstance(result["affectedModules"], list) + + +@pytest.mark.workspace("index_features") +async def test_rpc_impact_analysis_unknown(indexed_agentic, workspace): + rpc, _ = indexed_agentic + resp = rpc.request("agentic/impactAnalysis", {"path": "/nonexistent/file.cpp"}) + assert "result" in resp + assert resp["result"]["directDependents"] == [] + + +async def test_shutdown_during_indexing(executable, tmp_path): + """Shutdown during active background indexing must exit cleanly.""" + from tests.integration.utils.client import CliceClient + from tests.conftest import _find_free_port + + workspace = tmp_path / "ws" + workspace.mkdir() + + entries = [] + for i in range(20): + src = workspace / f"file_{i}.cpp" + src.write_text( + f"struct Type_{i} {{ int v = {i}; void m() {{}} }};\n" + f"int func_{i}(int x) {{ return x + {i}; }}\n" + f"int caller_{i}() {{ return func_{i}({i}); }}\n" + ) + entries.append( + { + "directory": workspace.as_posix(), + "file": src.as_posix(), + "arguments": ["clang++", "-std=c++17", "-fsyntax-only", src.as_posix()], + } + ) + + (workspace / "compile_commands.json").write_text(json.dumps(entries)) + + host = "127.0.0.1" + port = _find_free_port() + cmd = [str(executable), "--mode", "pipe", "--host", host, "--port", str(port)] + + c = CliceClient() + await c.start_io(*cmd) + + init_options = { + "project": { + "cache_dir": str(workspace / ".clice"), + "idle_timeout_ms": 0, + } + } + await c.initialize(workspace, initialization_options=init_options) + + # Give indexing a moment to start, then send shutdown + await asyncio.sleep(0.5) + + rpc = AgenticRpcClient(host, port) + body = json.dumps({"jsonrpc": "2.0", "method": "agentic/shutdown", "params": {}}) + rpc.sock.sendall(f"Content-Length: {len(body)}\r\n\r\n{body}".encode()) + rpc.sock.settimeout(5) + try: + rpc.sock.recv(4096) + except (socket.timeout, OSError): + pass + rpc.sock.close() + + for _ in range(30): + if c._server.returncode is not None: + break + await asyncio.sleep(0.5) + + assert c._server.returncode is not None, "Server did not exit after shutdown" + assert c._server.returncode >= 0, ( + f"Server crashed with signal {-c._server.returncode}" + ) diff --git a/tests/integration/agentic/test_cli.py b/tests/integration/agentic/test_cli.py new file mode 100644 index 00000000..8e14ed18 --- /dev/null +++ b/tests/integration/agentic/test_cli.py @@ -0,0 +1,189 @@ +"""CLI-based tests for agentic mode — run clice --mode agentic as a subprocess.""" + +import json +import subprocess + +import pytest + +from tests.integration.utils.wait import wait_for_index + + +def run_cli(executable, host, port, method, **kwargs): + cmd = [ + str(executable), + "--mode", + "agentic", + "--host", + host, + "--port", + str(port), + "--method", + method, + ] + for k, v in kwargs.items(): + cmd.extend([f"--{k}", str(v)]) + result = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + return result + + +@pytest.fixture +async def indexed_server(request, executable, workspace): + """Start server with LSP+agentic, compile a file, wait for indexing.""" + import asyncio + from tests.integration.utils.client import CliceClient + from tests.conftest import _shutdown_client, _find_free_port + + host = "127.0.0.1" + port = _find_free_port() + cmd = [str(executable), "--mode", "pipe", "--host", host, "--port", str(port)] + + c = CliceClient() + await c.start_io(*cmd) + + init_options = {"project": {"cache_dir": str(workspace / ".clice")}} + await c.initialize(workspace, initialization_options=init_options) + + uri, _ = await c.open_and_wait(workspace / "main.cpp") + assert await wait_for_index(c, uri, "add"), "Index not ready" + + from tests.integration.agentic.test_agentic import AgenticRpcClient + + rpc = AgenticRpcClient(host, port) + for _ in range(30): + resp = rpc.request("agentic/symbolSearch", {"query": "add"}) + if "result" in resp and resp["result"]["symbols"]: + break + await asyncio.sleep(1) + rpc.close() + + yield executable, host, port, workspace + + c.close(uri) + await _shutdown_client(c) + + +@pytest.mark.workspace("index_features") +async def test_cli_compile_command(indexed_server, workspace): + exe, host, port, _ = indexed_server + path = (workspace / "main.cpp").as_posix() + r = run_cli(exe, host, port, "compileCommand", path=path) + assert r.returncode == 0, f"stderr: {r.stderr}" + data = json.loads(r.stdout) + assert data["file"] == path + assert len(data["arguments"]) > 0 + + +@pytest.mark.workspace("index_features") +async def test_cli_symbol_search(indexed_server, workspace): + exe, host, port, _ = indexed_server + r = run_cli(exe, host, port, "symbolSearch", query="add") + assert r.returncode == 0, f"stderr: {r.stderr}" + data = json.loads(r.stdout) + names = [s["name"] for s in data["symbols"]] + assert "add" in names + add_sym = next(s for s in data["symbols"] if s["name"] == "add") + assert add_sym["kind"] == "Function" + assert add_sym["line"] == 19 + + +@pytest.mark.workspace("index_features") +async def test_cli_definition(indexed_server, workspace): + exe, host, port, _ = indexed_server + r = run_cli(exe, host, port, "definition", name="add") + assert r.returncode == 0, f"stderr: {r.stderr}" + data = json.loads(r.stdout) + assert data["name"] == "add" + defn = data["definition"] + assert defn["startLine"] == 19 + assert defn["endLine"] == 21 + assert "return a + b;" in defn["text"] + + +@pytest.mark.workspace("index_features") +async def test_cli_definition_by_position(indexed_server, workspace): + exe, host, port, _ = indexed_server + path = (workspace / "main.cpp").as_posix() + r = run_cli(exe, host, port, "definition", path=path, line=19) + assert r.returncode == 0, f"stderr: {r.stderr}" + data = json.loads(r.stdout) + assert data["name"] == "add" + + +@pytest.mark.workspace("index_features") +async def test_cli_references(indexed_server, workspace): + exe, host, port, _ = indexed_server + r = run_cli(exe, host, port, "references", name="global_var") + assert r.returncode == 0, f"stderr: {r.stderr}" + data = json.loads(r.stdout) + assert data["name"] == "global_var" + assert data["total"] == 2 + lines = sorted(ref["line"] for ref in data["references"]) + assert lines == [34, 38] + + +@pytest.mark.workspace("index_features") +async def test_cli_read_symbol(indexed_server, workspace): + exe, host, port, _ = indexed_server + r = run_cli(exe, host, port, "readSymbol", name="compute") + assert r.returncode == 0, f"stderr: {r.stderr}" + data = json.loads(r.stdout) + assert data["name"] == "compute" + assert "add(1, 2)" in data["text"] + + +@pytest.mark.workspace("index_features") +async def test_cli_document_symbols(indexed_server, workspace): + exe, host, port, _ = indexed_server + path = (workspace / "main.cpp").as_posix() + r = run_cli(exe, host, port, "documentSymbols", path=path) + assert r.returncode == 0, f"stderr: {r.stderr}" + data = json.loads(r.stdout) + names = [s["name"] for s in data["symbols"]] + assert "add" in names + assert "main" in names + assert "global_var" in names + + +@pytest.mark.workspace("index_features") +async def test_cli_call_graph(indexed_server, workspace): + exe, host, port, _ = indexed_server + r = run_cli(exe, host, port, "callGraph", name="add", direction="callers") + assert r.returncode == 0, f"stderr: {r.stderr}" + data = json.loads(r.stdout) + assert data["root"]["name"] == "add" + caller_names = [c["name"] for c in data["callers"]] + assert "compute" in caller_names + + +@pytest.mark.workspace("index_features") +async def test_cli_type_hierarchy(indexed_server, workspace): + exe, host, port, _ = indexed_server + r = run_cli(exe, host, port, "typeHierarchy", name="Dog", direction="supertypes") + assert r.returncode == 0, f"stderr: {r.stderr}" + data = json.loads(r.stdout) + assert data["root"]["name"] == "Dog" + supertype_names = [t["name"] for t in data["supertypes"]] + assert "Animal" in supertype_names + + +@pytest.mark.workspace("index_features") +async def test_cli_project_files(indexed_server, workspace): + exe, host, port, _ = indexed_server + r = run_cli(exe, host, port, "projectFiles") + assert r.returncode == 0, f"stderr: {r.stderr}" + data = json.loads(r.stdout) + assert data["total"] > 0 + paths = [f["path"] for f in data["files"]] + assert any("main.cpp" in p for p in paths) + + +@pytest.mark.workspace("index_features") +async def test_cli_status(indexed_server, workspace): + exe, host, port, _ = indexed_server + r = run_cli(exe, host, port, "status") + assert r.returncode == 0, f"stderr: {r.stderr}" + data = json.loads(r.stdout) + assert isinstance(data["idle"], bool) + assert data["total"] > 0 + assert isinstance(data["pending"], int) + assert isinstance(data["indexed"], int)