diff --git a/README.md b/README.md index 74773be5..9f5a9f9d 100644 --- a/README.md +++ b/README.md @@ -55,51 +55,31 @@ Then start the command line app with: ursa --config config.yaml ``` -This will start a REPL in your terminal. - -``` - __ ________________ _ - / / / / ___/ ___/ __ `/ -/ /_/ / / (__ ) /_/ / -\__,_/_/ /____/\__,_/ - -For help, type: ? or help. Exit with Ctrl+d. -ursa> -``` - -Within the REPL, you can get help by typing `?` or `help`. +This starts the full-screen terminal app. Type `/` to browse commands, +`#` to choose an agent behavior, or `@` to insert a workspace path. You can chat with an LLM by simply typing into the terminal. ``` -ursa> How are you? +How are you? Thanks for asking! I’m doing well. How are you today? What can I help you with? ``` -You can run various agents by typing the name of the agent. For example, +Use the required `#` macro to route a prompt to another agent behavior: ``` -ursa> plan -plan: Write a python script to do linear regression using only numpy. -``` - -Or by prepending the agent name to the query: - -```shell -ursa> plan Write a python script to do linear regression using only numpy. +#plan Write a python script to do linear regression using only numpy. ``` If you run subsequent agents, the last output will be appended to the prompt for the next agent. So, to run the Planning Agent followed by the Execution Agent: ``` -ursa> plan -plan: Write a python script to do linear regression using only numpy. +#plan Write a python script to do linear regression using only numpy. ... -ursa> execute -execute: Execute the plan. +#execute Execute the plan. ``` You can get a list of available command line options via diff --git a/docs/command_line.md b/docs/command_line.md index 49708346..bea078cb 100644 --- a/docs/command_line.md +++ b/docs/command_line.md @@ -12,55 +12,29 @@ To use the command line app, run ursa --llm_model.model openai:gpt-5.2 ``` -This will start a REPL in your terminal. - -``` - __ ________________ _ - / / / / ___/ ___/ __ `/ -/ /_/ / / (__ ) /_/ / -\__,_/_/ /____/\__,_/ - -For help, type: ? or help. Exit with Ctrl+d. -ursa> -``` - -Within the REPL, you can get help by typing `?` or `help`. +This starts the full-screen terminal app. Type `/` to browse commands, +`#` to choose an agent behavior, or `@` to insert a workspace path. +See [Getting Started - CLI](getting-started/cli.md#full-screen-interface-controls) +for prompt editing, multiline input, clipboard, and exit behavior. You can chat with an LLM by simply typing into the terminal. ``` -ursa> How are you? +How are you? Thanks for asking! I’m doing well. How are you today? What can I help you with? ``` -You can run various agents by typing the name of the agent. For example, - -``` -ursa> plan -plan: Write a python script to do linear regression using only numpy. -``` - -Or by prepending the agent name to the query: +Use the required `#` macro to route a prompt to another agent behavior: -```shell -ursa> plan Write a python script to do linear regression using only numpy. ``` - -If you run subsequent agents, the last output will be appended to the prompt for the next agent. - -So, to run the Planning Agent followed by the Execution Agent: +#plan Write a python script to do linear regression using only numpy. ``` -ursa> plan -plan: Write a python script to do linear regression using only numpy. -... - -ursa> execute -execute: Execute the plan. -``` +Agent macros route only the prompt in which they appear. Output from a previous +agent is not automatically appended to the next prompt; quote or reference any +needed result explicitly when switching behaviors. You can get a list of available command line options via ``` ursa --help ``` - diff --git a/docs/getting-started/cli.md b/docs/getting-started/cli.md index f50ddc5b..5a5fddf1 100644 --- a/docs/getting-started/cli.md +++ b/docs/getting-started/cli.md @@ -1,103 +1,100 @@ # Getting Started - CLI -This guide walks through starting URSA from the terminal, configuring a model, chatting with the default assistant, and running the planning and execution agents. +This guide walks through starting URSA from the terminal, chatting with the +default assistant, and routing messages to the planning and execution agents. ## Prerequisites - URSA is installed. See [Installation](../installation/index.md). -- You have access to an LLM endpoint. +- `OPENAI_API_KEY` is set for the default OpenAI endpoint. - You have a dedicated workspace directory for files URSA may create or modify. !!! warning "Be aware of your workspace" The execution agent can write files and run shell commands. Be careful using workspaces with source tree or data directory you cannot risk modifying. Good practice is to make backups or copies of directories before working. -## 1. Create a configuration file +For Ollama, Anthropic, Google GenAI, custom OpenAI-compatible endpoints, and +configuration files, see [Configuration](../configuration/index.md). -YAML configuration files are reusable and easy to edit. URSA configs can be -layered; see -[Configuration files, CLI flags, and environment variables][configuration-files-cli-flags-and-environment-variables] -for the precedence order. +## 1. Start URSA -Create `config.yaml`: - -```yaml -llm_model: - model: openai:gpt-5.4 - api_key: - env: OPENAI_API_KEY -workspace: . -``` - -Then set your API key in the shell: +Set your OpenAI API key and launch URSA: === "macOS/Linux" ```bash export OPENAI_API_KEY="..." + ursa ``` === "Windows PowerShell" ```powershell $env:OPENAI_API_KEY = "..." + ursa ``` -See [Configuration](../configuration/index.md) for Ollama, Anthropic, Google GenAI, and custom OpenAI-compatible endpoints. +You should see the full-screen URSA interface. Type `/` to browse app +commands, `#` to route a message to an agent, or `@` to insert a workspace +path. -## 2. Start URSA - -```bash -ursa --config config.yaml -``` - -You should see the URSA prompt: - -```text -ursa> -``` +### Full-screen interface controls -Type `help` or `?` inside the prompt to see available interactive commands. +| Input | Action | +|---|---| +| `/` | Browse application commands. Use `/keymap` for every keyboard shortcut. | +| `#` | Choose an agent and route the message to it. | +| `@` | Insert a workspace file or directory into the message. | +| **Enter** | Submit the message. | +| **Shift+Enter** or **Ctrl+J** | Insert a newline. Some terminals cannot distinguish Shift+Enter, so Ctrl+J is the portable option. | +| **Ctrl+Q** or `/exit` | Exit gracefully, waiting for an active turn to finish. | +| **Ctrl+D** | Exit immediately without cleanup; reserve this for a stuck turn. | -## 3. Chat with the assistant +## 2. Chat with the assistant ```text -ursa> Summarize what URSA can help me do. +Summarize what URSA can help me do. ``` Plain text input is handled by the default chat behavior. -## 4. Use the planning agent +## 3. Route a message to the planning agent -Run the planning agent with the `plan` command: +Route a message to the planning agent with the `#plan` macro. Typing `#` opens +the agent picker and inserts the selected agent at the front of the message: ```text -ursa> plan Write a plan for building a suite of surrogate models on data.csv and performing assessment of predictive capability and uncertainty quantification. +#plan Write a plan for building a suite of surrogate models on data.csv and performing assessment of predictive capability and uncertainty quantification. ``` -You can also type the agent name first and provide the prompt interactively: +The leading `#` is required; `plan ...` without it is ordinary chat input. -```text -ursa> plan -plan: Write a plan for building a suite of surrogate models on data.csv and performing assessment of predictive capability and uncertainty quantification. -``` - -## 5. Use the execution agent +## 4. Route a message to the execution agent The execution agent can write files and run commands in the configured workspace. ```text -ursa> execute Write and run a Python script that prints the first 10 prime numbers. +#execute Write and run a Python script that prints the first 10 prime numbers. ``` Review the actions and outputs carefully. For more safety guidance, see [Sandboxing and information control][sandboxing-and-information-control]. -## 6. Optional: use a named agent +To direct the agent to a particular workspace file, type `@` and choose it +from the path picker: + +```text +#execute Read @data/measurements.csv and create a histogram of the pressure column. +``` + +The picker inserts the path into the message; the receiving agent decides how +to use it and must have an appropriate file tool. + +## 5. Optional: use a named agent A named agent stores state so you can return to it later: ```bash -ursa --config config.yaml --name my-first-agent +ursa --name my-first-agent ``` For detailed commands to list, save, copy, share, import, and delete agents, see [Persistence](../persistence/index.md). @@ -105,15 +102,16 @@ For detailed commands to list, save, copy, share, import, and delete agents, see ## Useful CLI commands ```bash +ursa ursa --help ursa --print-config -ursa --config config.yaml -ursa --config config.yaml --name my-agent -ursa --config config.yaml --use-web +ursa --name my-agent +ursa --use-web ``` -Web tools are opt in. Use `--use-web` or `use_web: true` only when you want URSA -to make network requests through its web-search tools. +Web tools are opt in. Use `--use-web` only when you want URSA to make network +requests through its web-search tools. Configuration files and their +`use_web` setting are covered in [Configuration](../configuration/index.md). ## Where next? diff --git a/docs/index.md b/docs/index.md index 66d51d3e..b01dc3ae 100644 --- a/docs/index.md +++ b/docs/index.md @@ -65,11 +65,11 @@ Then run: ursa --config config.yaml ``` -Inside the URSA prompt, type `help` or try: +Inside the URSA app, type `/` to browse commands or try: ```text -ursa> Summarize what URSA can help me do. -ursa> execute Write and run a Python script that prints the first 10 prime numbers. +Summarize what URSA can help me do. +#execute Write and run a Python script that prints the first 10 prime numbers. ``` ## Where to go next diff --git a/docs/reference/tutorials/human-in-the-loop.md b/docs/reference/tutorials/human-in-the-loop.md deleted file mode 100644 index 3dbd72cc..00000000 --- a/docs/reference/tutorials/human-in-the-loop.md +++ /dev/null @@ -1,46 +0,0 @@ -# URSA Human-in-the-Loop Agent Interface Documentation - -## Previous Human-in-the-Loop Example has been Deprecated - -The HITL interface can now be accessed via the URSA CLI, launch with: - -`$ ursa` - -and help on commands can he accessed with - -`$ ursa --help` - - -## Basic Usage - -To prompt an URSA agent through the CLI, first select an agent, then issue a prompt to the agent: - -``` -ursa> execute -execute: Make me a histogram of the first 10000 prime number spacings -``` - -You can also issue the prompt in one line by prepending the agent name: -``` -ursa> execute Make me a histogram of the first 10000 prime number spacings` -``` - -to see the names of available agents, prompt the CLI with `help`: -``` -ursa> help - -Documented commands (type help ): -======================================== -EOF agents arxiv chat clear execute exit help models web - -Undocumented commands: -====================== -hypothesize plan - -``` - - -Some additional documentation on the URSA github repo: [LINK](https://github.com/lanl/ursa) -with more to come. - -We should have an in-depth documentation for it, but right now it's documented a bit on the main README and through the help flags with the CLI call. diff --git a/examples/hitl_examples/hitl_basic.py b/examples/hitl_examples/hitl_basic.py deleted file mode 100644 index a070f421..00000000 --- a/examples/hitl_examples/hitl_basic.py +++ /dev/null @@ -1,3 +0,0 @@ -print( - "This example has been depreciated and will be removed in a later version. For am improved version of the HITL interface, use the URSA CLI" -) diff --git a/mkdocs.yml b/mkdocs.yml index f5034b70..48acdfc5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -98,7 +98,6 @@ nav: - Plan-Execute checkpointing: reference/plan-execute-checkpointing.md - Tutorials and examples: - Combining arXiv agent and execution agent: reference/tutorials/arxiv-execution.md - - Human in the Loop: reference/tutorials/human-in-the-loop.md - Neutron Star: reference/tutorials/neutron-star.md - API Reference: - agents: reference/api/agents.md diff --git a/pyproject.toml b/pyproject.toml index f1bb60a8..aca67aeb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ dependencies = [ "pillow>=11.3.0,<12.0", "pymupdf>=1.28.0,<2.0", "pymupdf4llm>=0.3.4,<1.0", - "rich>=13.9.4,<14.0", + "rich>=14.2.0,<15.0", "ddgs>=9.14.4", "typer>=0.16.1", "truststore>=0.10.4,<1.0", @@ -41,13 +41,14 @@ dependencies = [ "langchain-google-genai>=4.2", "langchain-text-splitters>=1.0.0", "langchain-ollama>=1.0.0", - "langchain-mcp-adapters>=0.1.12,<0.2", + "langchain-mcp-adapters~=0.2.2", "pydantic>=2.12.0,<3.0", "mcp>=1.20.0,<2.0", "jsonargparse>=4.45.0", "fastmcp~=3.0", "pyyaml>=6.0.3", "justext>=3.0.2", + "textual[syntax]>=8.2.8,<9.0", "keyring>=25.0.0,<26.0.0", ] classifiers = [ @@ -132,7 +133,7 @@ pycodestyle.max-doc-length = 80 "examples/**/*.py" = ["T201", "TID251"] [tool.setuptools.package-data] -ursa = ["observability/pricing.json"] +ursa = ["cli/tui/*.tcss", "observability/pricing.json"] ursa_dashboard = ["logo/logo.png"] [tool.pytest.ini_options] diff --git a/src/ursa/agents/acquisition_agents.py b/src/ursa/agents/acquisition_agents.py index fff01c98..038ebe64 100644 --- a/src/ursa/agents/acquisition_agents.py +++ b/src/ursa/agents/acquisition_agents.py @@ -1,14 +1,15 @@ # generic_acquisition_agents.py +import asyncio import hashlib import json +import operator import os import re import shutil -import time -from concurrent.futures import ThreadPoolExecutor, as_completed from io import BytesIO -from typing import Any, Optional, TypedDict +from pathlib import Path +from typing import Annotated, Any, NotRequired, Optional, TypedDict from urllib.parse import quote, urlparse import feedparser @@ -19,6 +20,7 @@ from langchain.chat_models import BaseChatModel from langchain_core.output_parsers import StrOutputParser from langchain_core.prompts import ChatPromptTemplate +from langgraph.types import Overwrite, Send from PIL import Image from ursa.agents.base import BaseAgent @@ -62,7 +64,22 @@ class AcquisitionState(TypedDict, total=False): context: str items: list[ItemMetadata] summaries: list[str] - final_summary: str + final_summary: str | None + source_tasks: list["SourceTask"] + processed_sources: Annotated[list["ProcessedSource"], operator.add] + + +class SourceTask(TypedDict): + index: int + context: str + hit: NotRequired[dict[str, Any]] + cached_path: NotRequired[str] + + +class ProcessedSource(TypedDict): + index: int + item: ItemMetadata + summary: str | None # ---------- Small Utilities reused across agents ---------- @@ -87,11 +104,11 @@ def _looks_like_pdf_url(url: str) -> bool: def _download(url: str, dest_path: str, timeout: int = 20) -> str: - r = requests.get(url, stream=True, timeout=timeout) - r.raise_for_status() - os.makedirs(os.path.dirname(dest_path), exist_ok=True) - with open(dest_path, "wb") as f: - shutil.copyfileobj(r.raw, f) + with requests.get(url, stream=True, timeout=timeout) as response: + response.raise_for_status() + os.makedirs(os.path.dirname(dest_path), exist_ok=True) + with open(dest_path, "wb") as f: + shutil.copyfileobj(response.raw, f) return dest_path @@ -158,22 +175,23 @@ def extract_and_describe_images( return [f"[Image extraction failed: {e}]"] count = 0 - for pi in range(len(doc)): - if count >= max_images: - break - page = doc[pi] - for ji, img in enumerate(page.get_images(full=True)): + with doc: + for pi in range(len(doc)): if count >= max_images: break - xref = img[0] - base = doc.extract_image(xref) - image = Image.open(BytesIO(base["image"])) - try: - desc = describe_image(image) if OpenAI else "" - except Exception as e: - desc = f"[Error: {e}]" - descriptions.append(f"Page {pi + 1}, Image {ji + 1}: {desc}") - count += 1 + page = doc[pi] + for ji, img in enumerate(page.get_images(full=True)): + if count >= max_images: + break + xref = img[0] + base = doc.extract_image(xref) + with Image.open(BytesIO(base["image"])) as image: + try: + desc = describe_image(image) if OpenAI else "" + except Exception as e: + desc = f"[Error: {e}]" + descriptions.append(f"Page {pi + 1}, Image {ji + 1}: {desc}") + count += 1 return descriptions @@ -195,6 +213,8 @@ class BaseAcquisitionAgent(BaseAgent): - _filter_hit(self, hit) -> bool """ + state_type = AcquisitionState + def __init__( self, llm: BaseChatModel, @@ -207,9 +227,14 @@ def __init__( summaries_path: str = "acq_summaries", vectorstore_path: str = "acq_vectorstores", num_threads: int = 4, + max_concurrency: int | None = None, download: bool = True, **kwargs, ): + self.max_concurrency = max( + 1, num_threads if max_concurrency is None else max_concurrency + ) + self.num_threads = self.max_concurrency super().__init__(llm, **kwargs) self.summarize = summarize self.rag_embedding = rag_embedding @@ -219,11 +244,15 @@ def __init__( self.summaries_path = self.den / summaries_path self.vectorstore_path = self.den / vectorstore_path self.download = download - self.num_threads = num_threads self.database_path.mkdir(exist_ok=True, parents=True) self.summaries_path.mkdir(exist_ok=True, parents=True) + def build_config(self, **overrides) -> dict: + """Use LangGraph's executor to bound concurrent source tasks.""" + overrides.setdefault("max_concurrency", self.max_concurrency) + return super().build_config(**overrides) + # ---- abstract-ish methods ---- def _search(self, query: str) -> list[dict[str, Any]]: raise NotImplementedError @@ -231,6 +260,14 @@ def _search(self, query: str) -> list[dict[str, Any]]: def _materialize(self, hit: dict[str, Any]) -> ItemMetadata: raise NotImplementedError + async def _asearch(self, query: str) -> list[dict[str, Any]]: + """Run a synchronous source search without blocking the event loop.""" + return await asyncio.to_thread(self._search, query) + + async def _amaterialize(self, hit: dict[str, Any]) -> ItemMetadata: + """Materialize one source without blocking the event loop.""" + return await asyncio.to_thread(self._materialize, hit) + def _id(self, hit_or_item: dict[str, Any]) -> str: raise NotImplementedError @@ -258,57 +295,25 @@ def _postprocess_text(self, text: str, local_path: Optional[str]) -> str: return text # ---- shared nodes ---- - def _fetch_items(self, query: str) -> list[ItemMetadata]: - hits = self._search(query)[: self.max_results] if self.download else [] - items: list[ItemMetadata] = [] + async def _load_cached_item(self, path: Path) -> ItemMetadata: + def load() -> ItemMetadata: + try: + if path.suffix.lower() == ".pdf": + full_text = read_pdf(str(path)) + else: + full_text = path.read_text( + encoding="utf-8", errors="ignore" + ) + except Exception as exc: # noqa: BLE001 + full_text = f"[Error reading cached file: {exc}]" + full_text = self._postprocess_text(full_text, str(path)) + return { + "id": path.stem, + "local_path": str(path), + "full_text": full_text, + } - # If not downloading/scraping, try to load whatever is cached in database_path. - if not self.download: - for fname in os.listdir(self.database_path): - if fname.lower().endswith((".pdf", ".txt", ".html")): - item_id = os.path.splitext(fname)[0] - local_path = os.path.join(self.database_path, fname) - full_text = "" - try: - if fname.lower().endswith(".pdf"): - full_text = read_pdf(local_path) - else: - with open( - local_path, - "r", - encoding="utf-8", - errors="ignore", - ) as f: - full_text = f.read() - except Exception as e: - full_text = f"[Error reading cached file: {e}]" - full_text = self._postprocess_text(full_text, local_path) - items.append({ - "id": item_id, - "local_path": local_path, - "full_text": full_text, - }) - return items - - # Normal path: search → materialize each - with ThreadPoolExecutor( - max_workers=min(self.num_threads, max(1, len(hits))) - ) as ex: - futures = [ - ex.submit(self._materialize, h) - for h in hits - if self._filter_hit(h) - ] - for fut in as_completed(futures): - try: - item = fut.result() - items.append(item) - except Exception as e: - items.append({ - "id": _hash(str(time.time())), - "full_text": f"[Error: {e}]", - }) - return items + return await asyncio.to_thread(load) def _normalize_inputs(self, inputs) -> AcquisitionState: if isinstance(inputs, str): @@ -319,7 +324,7 @@ def _normalize_inputs(self, inputs) -> AcquisitionState: raise TypeError(f"Invalid input for {self.__class__.__name__}") def format_result(self, state: AcquisitionState) -> str: - if summary := state["final_summary"]: + if summary := state.get("final_summary"): return summary # Fallback to dumping an empty string if `self.summarize=False`. @@ -331,20 +336,57 @@ async def _search_query(self, state: AcquisitionState) -> AcquisitionState: """Generate a search query from the input search task (context)""" existing = state.get("query") if existing: - return state + return {} context = state["context"] query = await self.llm.ainvoke( f"The user stated {context}. Generate between 1 and 8 words for a search query to address the users need. Return only the words to search." ) - state["query"] = query.content or context - return state + return {"query": query.text or context} - def _fetch_node(self, state: AcquisitionState) -> AcquisitionState: - items = self._fetch_items(state["query"]) - return {**state, "items": items} + async def _search_sources( + self, state: AcquisitionState + ) -> AcquisitionState: + """Create source tasks for LangGraph's fan-out router.""" + context = state["context"] + if not self.download: + paths = sorted( + path + for path in self.database_path.iterdir() + if path.suffix.lower() in {".pdf", ".txt", ".html"} + ) + tasks = [ + SourceTask( + index=index, + context=context, + cached_path=str(path), + ) + for index, path in enumerate(paths) + ] + else: + hits = (await self._asearch(state["query"]))[: self.max_results] + tasks = [ + SourceTask(index=index, context=context, hit=hit) + for index, hit in enumerate(hits) + if self._filter_hit(hit) + ] + return { + "source_tasks": tasks, + "processed_sources": Overwrite([]), + } - def _summarize_node(self, state: AcquisitionState) -> AcquisitionState: + def _fan_out_sources(self, state: AcquisitionState) -> list[Send] | str: + """Dispatch one LangGraph task per source, or reduce an empty set.""" + tasks = state.get("source_tasks", []) + if not tasks: + return "_reduce_sources" + return [Send("_process_source", task) for task in tasks] + + async def _summarize_source( + self, item: ItemMetadata, index: int, context: str + ) -> str: + """Summarize one source inside its LangGraph fan-out task.""" + item_id = item.get("id", f"item_{index}") prompt = ChatPromptTemplate.from_template(""" You are an assistant responsible for summarizing retrieved content in the context of this task: {context} @@ -353,40 +395,61 @@ def _summarize_node(self, state: AcquisitionState) -> AcquisitionState: {retrieved_content} """) chain = prompt | self.llm | StrOutputParser() + try: + cleaned = remove_surrogates(item.get("full_text", "")) + summary = await chain.ainvoke( + {"retrieved_content": cleaned, "context": context}, + config=self.build_config(tags=["acq", "summarize_each"]), + ) + except Exception as exc: # noqa: BLE001 + summary = f"[Error summarizing item {item_id}: {exc}]" - if "items" not in state or not state["items"]: - return {**state, "summaries": None} - - summaries: list[Optional[str]] = [None] * len(state["items"]) - - def process(i: int, item: ItemMetadata): - item_id = item.get("id", f"item_{i}") - out_path = os.path.join( - self.summaries_path, f"{_safe_filename(item_id)}_summary.txt" + out_path = self.summaries_path / ( + f"{_safe_filename(item_id)}_summary.txt" + ) + try: + await asyncio.to_thread( + out_path.write_text, summary, encoding="utf-8" ) - try: - cleaned = remove_surrogates(item.get("full_text", "")) - summary = chain.invoke( - {"retrieved_content": cleaned, "context": state["context"]}, - config=self.build_config(tags=["acq", "summarize_each"]), + except (OSError, UnicodeError): + # Cache persistence is best-effort; the in-memory result is still + # useful and should be allowed to reach the reducer. + pass + return summary + + async def _process_source(self, task: SourceTask) -> AcquisitionState: + """Materialize and optionally summarize one fanned-out source.""" + index = task["index"] + hit = task.get("hit") + if hit is None: + cached_path = task.get("cached_path") + if cached_path is None: + raise ValueError( + "A source task must contain either 'hit' or 'cached_path'" ) - except Exception as e: - summary = f"[Error summarizing item {item_id}: {e}]" - with open(out_path, "w", encoding="utf-8") as f: - f.write(summary) - return i, summary - - with ThreadPoolExecutor( - max_workers=min(self.num_threads, len(state["items"])) - ) as ex: - futures = [ - ex.submit(process, i, it) for i, it in enumerate(state["items"]) - ] - for fut in as_completed(futures): - i, s = fut.result() - summaries[i] = s + item = await self._load_cached_item(Path(cached_path)) + else: + try: + item = await self._amaterialize(hit) + except Exception as exc: # noqa: BLE001 + item: ItemMetadata = { + "id": self._id(hit), + "title": str(hit.get("title") or ""), + "url": str(hit.get("href") or hit.get("url") or ""), + "full_text": f"[Error: {exc}]", + } - return {**state, "summaries": summaries} # type: ignore + summary = ( + await self._summarize_source(item, index, task["context"]) + if self.summarize and self.rag_embedding is None + else None + ) + processed = ProcessedSource( + index=index, + item=item, + summary=summary, + ) + return {"processed_sources": [processed]} def _rag_node(self, state: AcquisitionState) -> AcquisitionState: new_state = state.copy() @@ -402,24 +465,32 @@ def _rag_node(self, state: AcquisitionState) -> AcquisitionState: ] return new_state - def _aggregate_node(self, state: AcquisitionState) -> AcquisitionState: - if not state.get("summaries") or not state.get("items"): - return {**state, "final_summary": None} + async def _arag_node(self, state: AcquisitionState) -> AcquisitionState: + return await asyncio.to_thread(self._rag_node, state) + + async def _aggregate_sources( + self, + items: list[ItemMetadata], + summaries: list[str], + context: str, + ) -> str | None: + """Reduce ordered source summaries to the final answer.""" + if not summaries or not items: + return None blocks: list[str] = [] - for idx, (item, summ) in enumerate( - zip(state["items"], state["summaries"]) - ): # type: ignore + for idx, (item, summ) in enumerate(zip(items, summaries)): cite = self._citation(item) blocks.append(f"[{idx + 1}] {cite}\n\nSummary:\n{summ}") combined = "\n\n" + ("\n\n" + "-" * 40 + "\n\n").join(blocks) - with open( - os.path.join(self.summaries_path, "summaries_combined.txt"), - "w", - encoding="utf-8", - ) as f: - f.write(combined) + combined_path = self.summaries_path / "summaries_combined.txt" + try: + await asyncio.to_thread( + combined_path.write_text, combined, encoding="utf-8" + ) + except (OSError, UnicodeError): + pass prompt = ChatPromptTemplate.from_template(""" You are a scientific assistant extracting insights from multiple summaries. @@ -432,39 +503,79 @@ def _aggregate_node(self, state: AcquisitionState) -> AcquisitionState: """) chain = prompt | self.llm | StrOutputParser() - final_summary = chain.invoke( - {"Summaries": combined, "context": state["context"]}, + final_summary = await chain.ainvoke( + {"Summaries": combined, "context": context}, config=self.build_config(tags=["acq", "aggregate"]), ) - with open( - os.path.join(self.summaries_path, "final_summary.txt"), - "w", - encoding="utf-8", - ) as f: - f.write(final_summary) + final_path = self.summaries_path / "final_summary.txt" + try: + await asyncio.to_thread( + final_path.write_text, final_summary, encoding="utf-8" + ) + except (OSError, UnicodeError): + pass + + return final_summary + + async def _reduce_sources( + self, state: AcquisitionState + ) -> AcquisitionState: + """Collect LangGraph reducer output and produce the final state.""" + processed = sorted( + state.get("processed_sources", []), + key=lambda source: source["index"], + ) + items = [source["item"] for source in processed] + summaries = [ + source["summary"] + for source in processed + if source["summary"] is not None + ] + result: AcquisitionState = { + "items": items, + "summaries": summaries, + } + if self.summarize and self.rag_embedding is not None: + rag_state = await self._arag_node({**state, "items": items}) + result["final_summary"] = rag_state.get("final_summary") + elif self.summarize: + result["final_summary"] = await self._aggregate_sources( + items, summaries, state["context"] + ) + return result - return {**state, "final_summary": final_summary} + def _invoke(self, input, **config): + """Run the async-only graph for legacy synchronous callers.""" + try: + asyncio.get_running_loop() + except RuntimeError: + return asyncio.run(self._ainvoke(input, **config)) + raise RuntimeError( + "Acquisition agents are async-first; use `await agent.ainvoke(...)` " + "inside an active event loop." + ) + + def _stream(self, input, **config): + """Reject sync streaming because all acquisition nodes are async.""" + raise RuntimeError( + "Acquisition agents are async-first and do not support synchronous " + "`.stream()`. Use `await agent.ainvoke(...)` instead." + ) def _build_graph(self): self.add_node(self._search_query) + self.add_node(self._search_sources) + self.add_node(self._process_source) + self.add_node(self._reduce_sources) self.graph.set_entry_point("_search_query") - self.add_node(self._fetch_node) - self.graph.add_edge("_search_query", "_fetch_node") - - if self.summarize: - if self.rag_embedding: - self.add_node(self._rag_node) - self.graph.add_edge("_fetch_node", "_rag_node") - self.graph.set_finish_point("_rag_node") - else: - self.add_node(self._summarize_node) - self.add_node(self._aggregate_node) - - self.graph.add_edge("_fetch_node", "_summarize_node") - self.graph.add_edge("_summarize_node", "_aggregate_node") - self.graph.set_finish_point("_aggregate_node") - else: - self.graph.set_finish_point("_fetch_node") + self.graph.add_edge("_search_query", "_search_sources") + self.graph.add_conditional_edges( + "_search_sources", + self._fan_out_sources, + ["_process_source", "_reduce_sources"], + ) + self.graph.add_edge("_process_source", "_reduce_sources") + self.graph.set_finish_point("_reduce_sources") # ---------- Concrete: Web Search via ddgs ---------- @@ -526,9 +637,9 @@ def _materialize(self, hit: dict[str, Any]) -> ItemMetadata: _download(url, local_path) full_text = read_pdf(local_path) else: - r = requests.get(url, headers=headers, timeout=20) - r.raise_for_status() - html = r.text + with requests.get(url, headers=headers, timeout=20) as response: + response.raise_for_status() + html = response.text local_path = os.path.join( self.database_path, _safe_filename(item_id) + ".html" ) @@ -614,7 +725,7 @@ def _search(self, query: str) -> list[dict[str, Any]]: except Exception as e: return [ { - "id": _hash(query + str(time.time())), + "id": _hash(query + ":search-error"), "title": "Search error", "error": str(e), } @@ -698,14 +809,16 @@ def _materialize(self, hit: dict[str, Any]) -> ItemMetadata: full_text = f"[Error materializing OSTI {item_id}: {e}]" full_text = self._postprocess_text(full_text, local_path) - return { + item: ItemMetadata = { "id": item_id, "title": title, - "url": landing, "local_path": local_path, "full_text": full_text, "extra": {"raw_hit": hit}, } + if landing: + item["url"] = landing + return item # ---------- (Optional) Refactor your ArxivAgent to reuse the parent ---------- @@ -759,9 +872,9 @@ def _search(self, query: str) -> list[dict[str, Any]]: enc = quote(query) url = f"http://export.arxiv.org/api/query?search_query=all:{enc}&start=0&max_results={self.max_results}" try: - resp = requests.get(url, timeout=15) - resp.raise_for_status() - feed = feedparser.parse(resp.content) + with requests.get(url, timeout=15) as response: + response.raise_for_status() + feed = feedparser.parse(response.content) entries = feed.entries if hasattr(feed, "entries") else [] hits = [] for e in entries: @@ -775,7 +888,7 @@ def _search(self, query: str) -> list[dict[str, Any]]: except Exception as e: return [ { - "id": _hash(query + str(time.time())), + "id": _hash(query + ":search-error"), "title": "Search error", "error": str(e), } diff --git a/src/ursa/agents/base.py b/src/ursa/agents/base.py index e3212c39..a781c860 100644 --- a/src/ursa/agents/base.py +++ b/src/ursa/agents/base.py @@ -84,6 +84,7 @@ prune_sqlite_checkpoints, ) from ursa.util.events import DEFAULT_EVENT_LOGGING_HANDLER, AgentEvents +from ursa.util.mcp import load_mcp_tools_with_sources logger = logging.getLogger(__name__) @@ -1638,20 +1639,42 @@ async def add_mcp_tools( self, client: MultiServerMCPClient, tool_name: None | str | list[str] = None, - ) -> None: + ) -> dict[str, str]: """Add tools from an MCP client to the agent Args: client: the MCP client to add tools from tool_name: if provided, only add named tools + + Returns: + MCP server names keyed by the attached tool name. Clients that do + not expose named connections return an empty mapping. """ - tools = await client.get_tools() - if tool_name is not None: - tool_name = ( - tool_name if isinstance(tool_name, list) else [tool_name] + + def discover_and_apply() -> dict[str, str]: + # MCP adapters perform synchronous session/schema setup around + # their async I/O, and applying tools rebuilds the graph. Own one + # worker boundary for the complete operation so every caller—not + # only Textual—keeps its event loop responsive. + tools, tool_sources = asyncio.run( + load_mcp_tools_with_sources(client) ) - tools = [tool for tool in tools if tool.name in tool_name] - self.add_tool(tools) + selected = tool_name + if selected is not None: + selected = ( + selected if isinstance(selected, list) else [selected] + ) + tools = [tool for tool in tools if tool.name in selected] + attached_names = {tool.name for tool in tools} + tool_sources = { + name: server + for name, server in tool_sources.items() + if name in attached_names + } + self.add_tool(tools) + return tool_sources + + return await asyncio.to_thread(discover_and_apply) def remove_tool(self, tool_names: str | list[str]) -> None: names = tool_names if isinstance(tool_names, list) else [tool_names] diff --git a/src/ursa/cli/__init__.py b/src/ursa/cli/__init__.py index c515014e..84bfda9c 100644 --- a/src/ursa/cli/__init__.py +++ b/src/ursa/cli/__init__.py @@ -153,16 +153,13 @@ def build_parser() -> ArgumentParser: def _initialize_hitl(config: UrsaConfig): - """Create the CLI controller and report provider initialization errors cleanly.""" - from ursa.cli.hitl import HITL + """Create the runtime and report its contextual validation error cleanly.""" + from ursa.cli.runtime import HITL try: return HITL(config) except Exception as exc: - print( # noqa: T201 - "Error: unable to initialize the language model. " + str(exc), - file=sys.stderr, - ) + print(f"Error: {exc}", file=sys.stderr) # noqa: T201 raise SystemExit(2) from None @@ -297,10 +294,10 @@ def main(args=None): match subcommand: case None: - from ursa.cli.hitl import UrsaRepl + from ursa.cli.tui.app import run_textual hitl = _initialize_hitl(ursa_config) - UrsaRepl(hitl).run() + run_textual(hitl) case "mcp-server": hitl = _initialize_hitl(ursa_config) @@ -315,10 +312,10 @@ def main(args=None): run_kwargs["port"] = cmd_config.port mcp.run(**run_kwargs) case "exec": - from ursa.cli.hitl import UrsaRepl + from ursa.cli.tui.app import run_textual_once hitl = _initialize_hitl(ursa_config) - UrsaRepl(hitl).run_prompt(cmd_config.prompt) + run_textual_once(hitl, cmd_config.prompt) case _: logging.error(f"Unknown subcommand {subcommand}") raise NotImplementedError diff --git a/src/ursa/cli/config.py b/src/ursa/cli/config.py index 85561340..571f229d 100644 --- a/src/ursa/cli/config.py +++ b/src/ursa/cli/config.py @@ -303,6 +303,16 @@ def check_instantiated_model(self, model): f"Model base url ({model_url}) and config ({self.base_url}) do not match" ) + def pretty_repr(self, short: bool = False) -> str: + if short: + return f"{self.model} ({self.inference_provider or self.base_url})" + return f"{self.model} ({self.endpoint_repr()})" + + def endpoint_repr(self): + if self.inference_provider is None: + return self.base_url + return f"{self.inference_provider} - {self.base_url}" + class ChatModelConfig(ModelConfig): """Configuration for instantiating a chat model""" diff --git a/src/ursa/cli/hitl.py b/src/ursa/cli/hitl.py deleted file mode 100644 index 4eb4bdf7..00000000 --- a/src/ursa/cli/hitl.py +++ /dev/null @@ -1,495 +0,0 @@ -# ruff: noqa: TID251 - -import asyncio -import logging -import os -import sys -import threading -from cmd import Cmd -from collections.abc import Sequence -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any - -import aiosqlite -from fastmcp import FastMCP -from langchain.chat_models import BaseChatModel -from langchain_mcp_adapters.client import MultiServerMCPClient -from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver -from rich.console import Console -from rich.markdown import Markdown -from rich.panel import Panel -from rich.text import Text -from rich.theme import Theme - -from ursa import agents -from ursa.agents import BaseAgent -from ursa.agents.base import URSA_VERSION, AgentWithTools -from ursa.cli.callbacks import HITLLogEventHandler -from ursa.cli.config import UrsaConfig, resolve_ursa_config -from ursa.security import ( - enforce_model_group_policy, -) -from ursa.util.has_optional_dep_group import has_optional_dep_group -from ursa.util.mcp import start_mcp_client - -ursa_banner = rf""" - __ ________________ _ - / / / / ___/ ___/ __ `/ -/ /_/ / / (__ ) /_/ / -\__,_/_/ /____/\__,_/ v{URSA_VERSION} -""" - - -@dataclass -class AgentHITL: - """Wrapper for BaseAgent to delay instantiation and async method calls""" - - agent_class: Any - config: dict = field(default_factory=dict) - state: Any | None = None - _agent: BaseAgent | None = field(default=None, init=False) - - async def instantiate( - self, mcp_client: MultiServerMCPClient | None = None, **kwargs - ): - """Instantiate the underlying agent instance""" - assert self._agent is None - kwargs |= self.config - try: - self._agent = self.agent_class(**kwargs) - except TypeError as exc: - raise TypeError( - f"Failed to instantiate {self.agent_class.__name__} with config " - f"{self.config}. {exc}" - ) from exc - - # Attach tools from MCP client - if mcp_client and isinstance(self._agent, AgentWithTools): - await self._agent.add_mcp_tools(mcp_client) - - @property - def description(self): - if self._agent is None: - return self.agent_class.__doc__ - return self._agent.__doc__ - - async def __call__( - self, - prompt: str, - last_agent_result: str | None = None, - last_agent: Any | None = None, - callbacks: Sequence[Any] | None = None, - ) -> str: - assert self._agent is not None, "Agent not yet instantiated" - agent = self._agent - - # Inject the previous agent's response into the query - if (last_agent_result is not None) and (last_agent != agent): - prompt = "\n".join([ - f"The last agent output was: {last_agent_result}\n\n", - f"The user stated: {prompt}", - ]) - - # Setup the agents input state from it's current state and plain text input - # then invoke the agent and extract a final message from it's new state - query = agent.format_query(prompt, state=self.state) - - invoke_config = None - if callbacks: - invoke_config = {"callbacks": list(callbacks)} - - new_state = await agent.ainvoke(query, config=invoke_config) - msg = agent.format_result(new_state) - self.state = new_state - - # Return only the result message - return msg - - -def get_base_url(model: BaseChatModel) -> str | None: - for attr in ["base_url", "api_base", "openai_api_base"]: - if base_url := getattr(model, attr, None): - return base_url - logging.warning(f"Missing base_url for {model}") - return None - - -class HITL: - def __init__(self, config: UrsaConfig): - self.config = resolve_ursa_config(config) - self.thread_id = self.config.thread_id or "ursa" - # expose workspace and init common attributes - self.workspace = self.config.workspace - self.config.workspace.mkdir(parents=True, exist_ok=True) - - self.agent_name = self.config.agent_name - self.group = self.config.group - - self.model: BaseChatModel = self.config.llm_model.init_chat_model() - enforce_model_group_policy(self.model, self.group) - - self.embedding = ( - self.config.emb_model.init_embedding() - if self.config.emb_model is not None - else None - ) - enforce_model_group_policy(self.embedding, self.group) - - self.mcp_client = start_mcp_client(self.config.mcp_servers) - - rag_tool_config = { - "rag_tools": self.config.rag_tools, - "rag_tool_embedding": self.embedding, - } - - self.agents: dict[str, AgentHITL] = {} - for agent_name, agent_class_name, deps in [ - ("chat", "ChatAgent", None), - ("arxiv", "ArxivAgent", None), - ("dsi", "DSIAgent", "dsi"), - ("execute", "ExecutionAgent", None), - ("deep_review", "DeepReviewAgent", None), - ("hypothesize", "HypothesizerAgent", None), - ("plan", "PlanningAgent", None), - ("prompt", "PromptingAgent", None), - ("web", "WebSearchAgent", None), - ("lammps", "LammpsAgent", "lammps"), - ]: - if deps is not None and not has_optional_dep_group(deps): - continue - - config = {} - if agent_name in {"chat", "execute", "deep_review", "dsi"}: - config.update(rag_tool_config) - self.agents[agent_name] = AgentHITL( - agent_class=getattr(agents, agent_class_name), - config=config, - ) - - # Apply agent-specific configuration overrides - for agent, agent_config in self.config.agent_config.items(): - assert agent in self.agents, ( - f"Unknown agent {agent}, Know agents: {','.join(self.agents.keys())}" - ) - self.agents[agent].config.update(agent_config) - logging.debug( - f"Updated {agent} config: {self.agents[agent].config}" - ) - - self.last_agent_result = None - self.last_agent = None - - async def _get_checkpointer( - self, checkpoint_path: Path - ) -> AsyncSqliteSaver: - checkpoint_path = checkpoint_path / "db" / "checkpointer.db" - checkpoint_path.parent.mkdir(parents=True, exist_ok=True) - conn = await aiosqlite.connect(str(checkpoint_path)) - return AsyncSqliteSaver(conn) - - async def get_agent(self, name: str): - agent = self.agents[name] - - # Lazily instantiate the agents - if agent._agent is None: - await agent.instantiate( - llm=self.model, - workspace=self.workspace, - agent_name=self.agent_name, - group=self.group, - mcp_client=self.mcp_client, - thread_id=f"{self.thread_id}", - ) - # Named agents are persistent. Replace their sync checkpointer with - # an async one for HITL execution. Unnamed CLI sessions are - # ephemeral and intentionally run without a checkpointer so they do - # not leave checkpoint files in the workspace. - if self.agent_name is not None: - async_checkpointer = await self._get_checkpointer( - agent._agent.den - ) - agent._agent.checkpointer = async_checkpointer - - assert agent._agent is not None - return agent - - async def run_agent( - self, - name: str, - prompt: str, - callbacks: Sequence[Any] | None = None, - ) -> str: - assert name in self.agents, f"Unknown agent {name}" - agent = await self.get_agent(name) - msg = await agent( - prompt, - last_agent_result=self.last_agent_result, - last_agent=self.last_agent, - callbacks=callbacks, - ) - assert isinstance(msg, str) - self.last_agent_result = msg - self.last_agent = agent._agent - return msg - - def as_mcp_server(self, **kwargs): - from ursa import __version__ as ursa_version - - mcp = FastMCP( - "URSA", - version=ursa_version, - on_duplicate="error", - **kwargs, - ) - - # Add all agents - for name, agent in self.agents.items(): - mcp.tool( - self._make_agent_tool(name), - name=name, - description=agent.description, - ) - - return mcp - - def _make_agent_tool(self, agent_name: str): - # Need to ensure the call_agent closure is correctly constructed - async def call_agent(prompt: str) -> str: - return await self.run_agent(agent_name, prompt) - - return call_agent - - -class AsyncLoopThread: - def __init__(self): - self.loop = asyncio.new_event_loop() - self.thread = threading.Thread(target=self._run, daemon=True) - self.thread.start() - - def _run(self): - asyncio.set_event_loop(self.loop) - self.loop.run_forever() - - def submit(self, coro): - return asyncio.run_coroutine_threadsafe(coro, self.loop).result() - -def safe_prompt() -> str: - base_prompt = "ursa 🐻> " - fallback_prompt = "ursa> " - - try: - base_prompt.encode(sys.stdout.encoding or "utf-8") - return base_prompt - except (UnicodeEncodeError, TypeError): - return fallback_prompt - -class UrsaRepl(Cmd): - exit_message: str = "[dim]Exiting ursa..." - prompt: str = safe_prompt() - - def __init__(self, hitl: HITL, **kwargs): - super().__init__(**kwargs) - self.hitl = hitl - self.ursa_loop = AsyncLoopThread() - self.console = Console( - file=self.stdout, - theme=Theme({ - "success": "green", - "error": "bold red", - "dim": "grey50", - "warn": "yellow", - "emph": "bold cyan", - }), - ) - - base_url = get_base_url(self.hitl.model) - if not base_url: - base_url = "Default" - - try: - model_name = self.hitl.model.model_name - except Exception: - model_name = self.hitl.model.model - self.llm_model_panel = Panel.fit( - Text.from_markup( - f"[bold]Workspace[/]: {Path(self.hitl.workspace).absolute()}\n" - f"[bold]LLM endpoint[/]: {base_url}\n" - f"[bold]LLM model[/]: {model_name}" - ), - border_style="cyan", - ) - self.emb_model_panel = None - if self.hitl.embedding: - base_url = get_base_url(self.hitl.embedding) - if not base_url: - base_url = "Default" - try: - model_name = self.hitl.embedding.model_name - except Exception: - model_name = self.hitl.embedding.model - self.emb_model_panel = Panel.fit( - Text.from_markup( - f"[bold]Embedding endpoint[/]: {base_url}\n" - f"[bold]Embedding model[/]: {model_name}" - ), - border_style="cyan", - ) - - def __getattribute__(self, name: str) -> Any: - # Dynamically add do_agent methods - if name.startswith("do_"): - agent_name = name.removeprefix("do_") - if agent_name in self.hitl.agents.keys(): - - def run_agent(prompt): - return self.run_agent(agent_name, prompt) - - run_agent.__doc__ = self.hitl.agents[agent_name].description - return run_agent - - return super().__getattribute__(name) - - @staticmethod - def help_message(): - if os.name == "nt": - exit_shortcut = "Crtl+Z" - elif os.name == "posix": - exit_shortcut = "Crtl+D" - else: - exit_shortcut = None - - msg = "[dim]For help, type: ? or help." - if exit_shortcut is None: - msg += " Exit by typing 'exit'." - else: - msg += f" Exit with {exit_shortcut} or by typing exit." - return msg - - def get_names(self) -> list[str]: - names = super().get_names() - for name in self.hitl.agents.keys(): - names.append(f"do_{name}") - return names - - def run_agent(self, name: str, prompt: str | None = None): - if not prompt: - prompt = input(f"{name}: ") - handler = HITLLogEventHandler( - console=self.console, - workspace=self.hitl.workspace, - ) - result = self.hitl.run_agent(name, prompt, callbacks=[handler]) - result = self.ursa_loop.submit(result) - - assert isinstance(result, str) - if handler.emitted_any: - self.console.print() - self.show(result) - - def run_prompt(self, prompt: str): - """Respond to a single prompt""" - prompt = self.precmd(prompt) - stop = self.onecmd(prompt) - return self.postcmd(stop, prompt) - - def show(self, msg: str, markdown: bool = True, **kwargs): - self.console.print(Markdown(msg) if markdown else msg, **kwargs) - - def default(self, prompt: str): - self.run_agent("chat", prompt) - - def postcmd(self, stop: bool, line: str): - # A dim rule chunks scrollback into per-turn blocks (issue 264). - self.console.print() - self.console.rule(style="dim") - return stop - - def do_exit(self, _: str): - """Exit shell.""" - self.show(self.exit_message, markdown=False) - return True - - def do_EOF(self, _: str): - """Exit on Ctrl+D.""" - self.show("\n" + self.exit_message, markdown=False) - return True - - def do_clear(self, _: str): - """Clear the screen. Same as pressing Ctrl+L.""" - os.system("cls" if os.name == "nt" else "clear") - - def emptyline(self): - """Do nothing when an empty line is entered""" - pass - - def run(self): - """Handle Ctrl+C to avoid quitting the program""" - # Print intro only once. - self.show(f"[magenta]{ursa_banner}", markdown=False, highlight=False) - self.show(self.llm_model_panel, markdown=False, highlight=False) - if self.emb_model_panel: - self.show(self.emb_model_panel, markdown=False, highlight=False) - self.show(self.help_message(), markdown=False) - - while True: - try: - self.cmdloop() - break # Allows breaking out of loop if EOF is triggered. - except KeyboardInterrupt: - print( # noqa: T201 - "\n(Interrupted) Press Ctrl+D to exit or continue typing." - ) - - def do_models(self, _: str): - """List models and base urls""" - llm_provider, llm_name = get_provider_and_model( - self.hitl.config.llm_model.model - ) - self.show( - f"[dim]*[/] LLM: [emph]{llm_name} " - f"[dim]{self.hitl.config.llm_model.base_url or llm_provider}", - markdown=False, - ) - - emb_provider, emb_name = ( - get_provider_and_model(self.hitl.config.emb_model.model) - if self.hitl.config.emb_model - else ("None", "None") - ) - if not emb_provider: - emb_provider = self.hitl.config.emb_model.base_url - self.show( - f"[dim]*[/] Embedding Model: [emph]{emb_name} [dim]{emb_provider}", - markdown=False, - ) - - def do_agents(self, _: str): - """Display configured Agents and their configurations""" - for name, agent in self.hitl.agents.items(): - if agent.config: - self.console.print(f"{name}:") - for k, v in agent.config.items(): - self.console.print(f" {k}: {v}") - else: - self.console.print(name + ": {}") - - -def get_provider_and_model(model_str: str | None): - if model_str is None: - return "none", "none" - - if ":" in model_str: - provider, model = model_str.split(":", 1) - else: - provider = "openai" - model = model_str - - return provider, model - - -# TODO: -# * Add option to swap models in REPL -# * Add option for seed setting via flags -# * Name change: --llm-model-name -> llm -# * Name change: --emb-model-name -> emb diff --git a/src/ursa/cli/runtime.py b/src/ursa/cli/runtime.py new file mode 100644 index 00000000..4ba2e60f --- /dev/null +++ b/src/ursa/cli/runtime.py @@ -0,0 +1,578 @@ +# ruff: noqa: TID251 + +import asyncio +import logging +from collections.abc import AsyncIterator, Awaitable, Callable, Sequence +from contextlib import asynccontextmanager +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import aiosqlite +from fastmcp import FastMCP +from langchain.chat_models import BaseChatModel +from langchain_mcp_adapters.client import MultiServerMCPClient +from langgraph.checkpoint.sqlite import SqliteSaver +from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver + +from ursa import agents +from ursa.agents import BaseAgent +from ursa.agents.base import AgentWithTools +from ursa.cli.config import ( + ChatModelConfig, + EmbModelConfig, + UrsaConfig, + resolve_ursa_config, +) +from ursa.security import ( + enforce_model_group_policy, +) +from ursa.util.has_optional_dep_group import has_optional_dep_group +from ursa.util.inference_providers import validate_model_provider +from ursa.util.mcp import start_mcp_client + + +@dataclass +class AgentHITL: + """Wrapper for BaseAgent to delay instantiation and async method calls""" + + agent_class: Any + config: dict = field(default_factory=dict) + state: Any | None = None + tool_sources: dict[str, str] = field(default_factory=dict, init=False) + _agent: BaseAgent | None = field(default=None, init=False) + _initialization_task: asyncio.Task[None] | None = field( + default=None, init=False, repr=False + ) + + async def instantiate( + self, + mcp_client: MultiServerMCPClient | None = None, + finalizer: Callable[[Any], Awaitable[None]] | None = None, + **kwargs, + ): + """Instantiate once, shared by all concurrent and cancelled waiters.""" + if self._agent is not None: + return + task = self._initialization_task + if task is None: + task = asyncio.create_task( + self._instantiate_once( + mcp_client=mcp_client, finalizer=finalizer, **kwargs + ) + ) + self._initialization_task = task + task.add_done_callback(self._initialization_done) + # A UI waiter may be cancelled when its modal closes. Initialization + # is runtime-owned and must still publish or clean up its result. + await asyncio.shield(task) + + def _initialization_done(self, task: asyncio.Task[None]) -> None: + if self._initialization_task is task: + self._initialization_task = None + if not task.cancelled(): + # Retrieve a failure even if the last UI waiter was cancelled; + # active waiters still receive the same exception from `await`. + task.exception() + + async def _instantiate_once( + self, + mcp_client: MultiServerMCPClient | None = None, + finalizer: Callable[[Any], Awaitable[None]] | None = None, + **kwargs, + ) -> None: + kwargs |= self.config + + def build_agent(): + try: + agent = self.agent_class(**kwargs) + except TypeError as exc: + raise TypeError( + f"Failed to instantiate {self.agent_class.__name__} with " + f"config {self.config}. {exc}" + ) from exc + return agent + + agent = await asyncio.to_thread(build_agent) + try: + tool_sources: dict[str, str] = {} + if mcp_client and isinstance(agent, AgentWithTools): + tool_sources = await agent.add_mcp_tools(mcp_client) + if finalizer is not None: + await finalizer(agent) + except BaseException: + await self._close_agent(agent) + raise + self._agent = agent + self.tool_sources = tool_sources + + @staticmethod + async def _close_agent(agent: Any) -> None: + async_close = getattr(agent, "aclose", None) + if callable(async_close): + try: + await async_close() + except Exception: + logging.exception("Failed to close partially initialized agent") + close = getattr(agent, "close", None) + if callable(close): + try: + await asyncio.to_thread(close) + except Exception: + logging.exception("Failed to close partially initialized agent") + + async def wait_until_initialized(self) -> None: + """Wait for runtime-owned initialization, if one is in flight.""" + if self._initialization_task is not None: + await asyncio.shield(self._initialization_task) + + @property + def description(self): + if self._agent is None: + return self.agent_class.__doc__ + return self._agent.__doc__ + + async def __call__( + self, + prompt: str, + last_agent_result: str | None = None, + last_agent: Any | None = None, + callbacks: Sequence[Any] | None = None, + ) -> str: + assert self._agent is not None, "Agent not yet instantiated" + agent = self._agent + + # Inject the previous agent's response into the query + if (last_agent_result is not None) and (last_agent != agent): + prompt = "\n".join([ + f"The last agent output was: {last_agent_result}\n\n", + f"The user stated: {prompt}", + ]) + + # Setup the agents input state from it's current state and plain text input + # then invoke the agent and extract a final message from it's new state + query = agent.format_query(prompt, state=self.state) + + invoke_config = None + if callbacks: + invoke_config = {"callbacks": list(callbacks)} + + new_state = await agent.ainvoke(query, config=invoke_config) + msg = agent.format_result(new_state) + self.state = new_state + + # Return only the result message + return msg + + +def get_base_url(model: BaseChatModel) -> str | None: + for attr in ["base_url", "api_base", "openai_api_base"]: + if base_url := getattr(model, attr, None): + return base_url + logging.warning(f"Missing base_url for {model}") + return None + + +class HITL: + def __init__(self, config: UrsaConfig): + self.inference_provider = config.llm_model.inference_provider + self.embedding_inference_provider = ( + config.emb_model.inference_provider + if config.emb_model is not None + else None + ) + self.config = resolve_ursa_config(config) + self.thread_id = self.config.thread_id or "ursa" + # expose workspace and init common attributes + self.workspace = self.config.workspace + self.config.workspace.mkdir(parents=True, exist_ok=True) + + self.agent_name = self.config.agent_name + self.group = self.config.group + + validate_model_provider(self.config.llm_model, "chat") + if self.config.emb_model is not None: + validate_model_provider(self.config.emb_model, "embedding") + + self.model: BaseChatModel = self.config.llm_model.init_chat_model() + enforce_model_group_policy(self.model, self.group) + + self.embedding = ( + self.config.emb_model.init_embedding() + if self.config.emb_model is not None + else None + ) + enforce_model_group_policy(self.embedding, self.group) + + self.mcp_client = start_mcp_client(self.config.mcp_servers) + + rag_tool_config = { + "rag_tools": self.config.rag_tools, + "rag_tool_embedding": self.embedding, + } + + self.agents: dict[str, AgentHITL] = {} + for agent_name, agent_class_name, deps in [ + ("chat", "ChatAgent", None), + ("arxiv", "ArxivAgent", None), + ("dsi", "DSIAgent", "dsi"), + ("execute", "ExecutionAgent", None), + ("deep_review", "DeepReviewAgent", None), + ("hypothesize", "HypothesizerAgent", None), + ("plan", "PlanningAgent", None), + ("prompt", "PromptingAgent", None), + ("web", "WebSearchAgent", None), + ("lammps", "LammpsAgent", "lammps"), + ]: + if deps is not None and not has_optional_dep_group(deps): + continue + + config = {} + if agent_name in {"chat", "execute", "deep_review", "dsi"}: + config.update(rag_tool_config) + self.agents[agent_name] = AgentHITL( + agent_class=getattr(agents, agent_class_name), + config=config, + ) + + # Apply agent-specific configuration overrides + for agent, agent_config in self.config.agent_config.items(): + assert agent in self.agents, ( + f"Unknown agent {agent}, Know agents: {','.join(self.agents.keys())}" + ) + self.agents[agent].config.update(agent_config) + logging.debug( + f"Updated {agent} config: {self.agents[agent].config}" + ) + + self.last_agent_result = None + self.last_agent = None + self._runtime_checkpointers: list[AsyncSqliteSaver] = [] + self._transition_lock = asyncio.Lock() + self._transition_serial_lock = asyncio.Lock() + self._loads_allowed = asyncio.Event() + self._loads_allowed.set() + self._no_active_operations = asyncio.Event() + self._no_active_operations.set() + self._active_operations = 0 + self._close_task: asyncio.Task[None] | None = None + self._closed = False + + async def _get_checkpointer( + self, checkpoint_path: Path + ) -> AsyncSqliteSaver: + checkpoint_path = checkpoint_path / "db" / "checkpointer.db" + await asyncio.to_thread( + checkpoint_path.parent.mkdir, parents=True, exist_ok=True + ) + conn = await aiosqlite.connect(str(checkpoint_path)) + return AsyncSqliteSaver(conn) + + @asynccontextmanager + async def _agent_operation(self) -> AsyncIterator[None]: + """Lease runtime agent resources against close/reconfiguration.""" + while True: + if self._closed: + raise RuntimeError("HITL runtime is closed") + await self._loads_allowed.wait() + async with self._transition_lock: + if self._closed: + raise RuntimeError("HITL runtime is closed") + if not self._loads_allowed.is_set(): + continue + self._active_operations += 1 + self._no_active_operations.clear() + break + try: + yield + finally: + async with self._transition_lock: + self._active_operations -= 1 + if self._active_operations == 0: + self._no_active_operations.set() + + async def _begin_transition(self) -> None: + await self._transition_serial_lock.acquire() + try: + async with self._transition_lock: + self._loads_allowed.clear() + await self._no_active_operations.wait() + except BaseException: + async with self._transition_lock: + self._loads_allowed.set() + self._transition_serial_lock.release() + raise + + async def _end_transition(self) -> None: + async with self._transition_lock: + self._loads_allowed.set() + self._transition_serial_lock.release() + + @asynccontextmanager + async def _agent_transition(self) -> AsyncIterator[None]: + await self._begin_transition() + try: + yield + finally: + await self._end_transition() + + @staticmethod + def _consume_task_exception(task: asyncio.Task[Any]) -> None: + if not task.cancelled(): + task.exception() + + async def _run_runtime_task(self, coroutine: Awaitable[Any]) -> Any: + task = asyncio.create_task(coroutine) + task.add_done_callback(self._consume_task_exception) + return await asyncio.shield(task) + + async def _finalize_named_agent(self, built_agent: Any) -> None: + sync_checkpointer = built_agent.checkpointer + async_checkpointer = await self._get_checkpointer(built_agent.den) + try: + if isinstance(sync_checkpointer, SqliteSaver): + await asyncio.to_thread(sync_checkpointer.conn.close) + except BaseException: + await async_checkpointer.conn.close() + await asyncio.to_thread(async_checkpointer.conn.join) + raise + built_agent.checkpointer = async_checkpointer + self._runtime_checkpointers.append(async_checkpointer) + + async def _get_agent(self, name: str) -> AgentHITL: + agent = self.agents[name] + await agent.instantiate( + llm=self.model, + workspace=self.workspace, + agent_name=self.agent_name, + group=self.group, + mcp_client=self.mcp_client, + finalizer=( + self._finalize_named_agent + if self.agent_name is not None + else None + ), + thread_id=f"{self.thread_id}", + ) + assert agent._agent is not None + return agent + + async def get_agent(self, name: str): + async with self.use_agent(name) as agent: + return agent + + @asynccontextmanager + async def use_agent(self, name: str) -> AsyncIterator[AgentHITL]: + """Keep one agent alive for metadata extraction or execution setup.""" + async with self._agent_operation(): + yield await self._get_agent(name) + + async def run_agent( + self, + name: str, + prompt: str, + callbacks: Sequence[Any] | None = None, + ) -> str: + assert name in self.agents, f"Unknown agent {name}" + async with self.use_agent(name) as agent: + msg = await agent( + prompt, + last_agent_result=self.last_agent_result, + last_agent=self.last_agent, + callbacks=callbacks, + ) + assert isinstance(msg, str) + self.last_agent_result = msg + self.last_agent = agent._agent + return msg + + async def reconfigure_model( + self, model_name: str, inference_provider: str | None + ) -> None: + """Replace the chat model and reset agents bound to the old model. + + This method owns rollback: if replacement fails, callers may assume + the existing model configuration remains active. + """ + model_name = model_name.strip() + if not model_name: + raise ValueError("Model name cannot be empty") + if ( + inference_provider is not None + and inference_provider not in self.config.inference_providers + ): + raise ValueError( + f"Unknown inference provider '{inference_provider}'" + ) + + candidate = ChatModelConfig( + model=model_name, + inference_provider=inference_provider, + max_completion_tokens=self.config.llm_model.max_completion_tokens, + ) + resolved = candidate.resolve_inference_provider( + self.config.inference_providers + ) + await asyncio.to_thread(validate_model_provider, resolved, "chat") + model = await asyncio.to_thread(resolved.init_chat_model) + enforce_model_group_policy(model, self.group) + + async def apply_reconfiguration() -> None: + async with self._agent_transition(): + if self._closed: + raise RuntimeError("HITL runtime is closed") + await self._close_instantiated_agents() + self.model = model + self.config.llm_model = resolved + self.inference_provider = inference_provider + self.last_agent = None + self.last_agent_result = None + + await self._run_runtime_task(apply_reconfiguration()) + + async def reconfigure_models( + self, + chat_config: ChatModelConfig, + embedding_config: EmbModelConfig | None, + ) -> None: + """Replace chat and embedding models after both validate successfully. + + This method owns rollback: if replacement fails, callers may assume + the existing model configuration remains active. + """ + for provider in ( + chat_config.inference_provider, + embedding_config.inference_provider if embedding_config else None, + ): + if ( + provider is not None + and provider not in self.config.inference_providers + ): + raise ValueError(f"Unknown inference provider '{provider}'") + + if not chat_config.model.strip(): + raise ValueError("Chat model cannot be empty") + resolved_chat = chat_config.resolve_inference_provider( + self.config.inference_providers + ) + await asyncio.to_thread(validate_model_provider, resolved_chat, "chat") + new_chat = await asyncio.to_thread(resolved_chat.init_chat_model) + enforce_model_group_policy(new_chat, self.group) + + resolved_embedding = None + new_embedding = None + if embedding_config is not None: + resolved_embedding = embedding_config.resolve_inference_provider( + self.config.inference_providers + ) + await asyncio.to_thread( + validate_model_provider, resolved_embedding, "embedding" + ) + new_embedding = await asyncio.to_thread( + resolved_embedding.init_embedding + ) + enforce_model_group_policy(new_embedding, self.group) + + async def apply_reconfiguration() -> None: + async with self._agent_transition(): + if self._closed: + raise RuntimeError("HITL runtime is closed") + await self._close_instantiated_agents() + self.model = new_chat + self.embedding = new_embedding + self.config.llm_model = resolved_chat + self.config.emb_model = resolved_embedding + self.inference_provider = chat_config.inference_provider + self.embedding_inference_provider = ( + embedding_config.inference_provider + if embedding_config + else None + ) + for wrapper in self.agents.values(): + if "rag_tool_embedding" in wrapper.config: + wrapper.config["rag_tool_embedding"] = new_embedding + self.last_agent = None + self.last_agent_result = None + + await self._run_runtime_task(apply_reconfiguration()) + + async def _close_instantiated_agents(self) -> None: + """Close and reset agents so they bind to the configured model.""" + for wrapper in self.agents.values(): + try: + await wrapper.wait_until_initialized() + except Exception: + logging.exception("Agent initialization failed during cleanup") + agent = wrapper._agent + if agent is None: + continue + try: + await agent.aclose() + except Exception: + logging.exception("Failed to close async agent resources") + try: + await asyncio.to_thread(agent.close) + except Exception: + logging.exception("Failed to close sync agent resources") + wrapper._agent = None + wrapper.state = None + + for checkpointer in self._runtime_checkpointers: + try: + await checkpointer.conn.close() + await asyncio.to_thread(checkpointer.conn.join) + except Exception: + logging.exception("Failed to close agent checkpointer") + self._runtime_checkpointers.clear() + + async def aclose(self) -> None: + """Close instantiated agents and runtime-owned persistence resources.""" + task = self._close_task + if task is None: + task = asyncio.create_task(self._aclose_once()) + self._close_task = task + task.add_done_callback(self._close_done) + await asyncio.shield(task) + + async def _aclose_once(self) -> None: + async with self._agent_transition(): + if self._closed: + return + await self._close_instantiated_agents() + self._closed = True + + def _close_done(self, task: asyncio.Task[None]) -> None: + if self._close_task is task: + self._close_task = None + self._consume_task_exception(task) + + async def close(self) -> None: + """Compatibility alias for :meth:`aclose`.""" + await self.aclose() + + def as_mcp_server(self, **kwargs): + from ursa import __version__ as ursa_version + + mcp = FastMCP( + "URSA", + version=ursa_version, + on_duplicate="error", + **kwargs, + ) + + # Add all agents + for name, agent in self.agents.items(): + mcp.tool( + self._make_agent_tool(name), + name=name, + description=agent.description, + ) + + return mcp + + def _make_agent_tool(self, agent_name: str): + # Need to ensure the call_agent closure is correctly constructed + async def call_agent(prompt: str) -> str: + return await self.run_agent(agent_name, prompt) + + return call_agent diff --git a/src/ursa/cli/tui/__init__.py b/src/ursa/cli/tui/__init__.py new file mode 100644 index 00000000..f7874aba --- /dev/null +++ b/src/ursa/cli/tui/__init__.py @@ -0,0 +1 @@ +"""Textual user interface for the URSA command-line application.""" diff --git a/src/ursa/cli/tui/agent_info.py b/src/ursa/cli/tui/agent_info.py new file mode 100644 index 00000000..70d51e4e --- /dev/null +++ b/src/ursa/cli/tui/agent_info.py @@ -0,0 +1,212 @@ +"""Normalized agent and tool details for the Textual agent browser.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class ToolArgumentDetails: + """One argument accepted by a configured tool.""" + + name: str + type_name: str + required: bool + description: str + + +@dataclass(frozen=True) +class ToolDetails: + """Display-safe metadata for one configured tool.""" + + name: str + class_name: str + description: str + schema_name: str + return_direct: bool | None + arguments: tuple[ToolArgumentDetails, ...] + mcp_server: str = "" + + +@dataclass(frozen=True) +class AgentDetails: + """Description, configuration, and tools for one agent tab.""" + + name: str + description: str + config: tuple[tuple[str, str], ...] + tools: tuple[ToolDetails, ...] + tools_loaded: bool = True + tool_error: str = "" + + +def _schema_name(schema: Any) -> str: + if schema is None: + return "none" + return str( + getattr(schema, "__name__", None) + or getattr(schema.__class__, "__name__", None) + or schema + ) + + +def _schema_arguments(schema: Any) -> tuple[ToolArgumentDetails, ...]: + schema_json = schema if isinstance(schema, Mapping) else None + if schema_json is None: + for method_name in ("model_json_schema", "schema"): + method = getattr(schema, method_name, None) + if callable(method): + try: + schema_json = method() + except Exception: # pragma: no cover - provider schema code + continue + break + if not isinstance(schema_json, Mapping): + return () + properties = schema_json.get("properties") + if not isinstance(properties, Mapping): + return () + required = set(schema_json.get("required") or ()) + arguments = [] + for name, metadata in properties.items(): + metadata = metadata if isinstance(metadata, Mapping) else {} + type_name = str( + metadata.get("type") + or metadata.get("title") + or metadata.get("$ref") + or "any" + ) + arguments.append( + ToolArgumentDetails( + name=str(name), + type_name=type_name.rsplit("/", 1)[-1], + required=name in required, + description=str(metadata.get("description") or ""), + ) + ) + return tuple(arguments) + + +def _tool_details( + name: str, + tool: Any, + mcp_server: str = "", + *, + include_schema: bool = True, +) -> ToolDetails: + schema = getattr(tool, "args_schema", None) if include_schema else None + return ToolDetails( + name=str(getattr(tool, "name", None) or name), + class_name=tool.__class__.__name__, + description=str( + getattr(tool, "description", None) or "No description available." + ).strip(), + schema_name=_schema_name(schema), + return_direct=getattr(tool, "return_direct", None), + arguments=_schema_arguments(schema) if include_schema else (), + mcp_server=mcp_server, + ) + + +def _configured_tools( + agent: Any, + tool_sources: Mapping[str, str], + *, + include_schema: bool = True, +) -> tuple[ToolDetails, ...]: + tools = getattr(agent, "tools", None) + if isinstance(tools, Mapping): + items = tools.items() + elif isinstance(tools, (list, tuple)): + items = ( + (str(getattr(tool, "name", None) or index), tool) + for index, tool in enumerate(tools) + ) + else: + return () + return tuple( + _tool_details( + str(name), + tool, + str(tool_sources.get(str(name)) or ""), + include_schema=include_schema, + ) + for name, tool in sorted(items, key=lambda item: str(item[0])) + ) + + +def load_agent_details(hitl: Any) -> tuple[AgentDetails, ...]: + """Snapshot configured-agent metadata without instantiating any agents.""" + details = [] + # Dict insertion order is the canonical UI order established in HITL. + for name, wrapper in hitl.agents.items(): + description = str( + getattr(wrapper, "description", None) or "No description available." + ).strip() + config = tuple( + (str(key), str(value)) + for key, value in (getattr(wrapper, "config", None) or {}).items() + ) + actual = ( + getattr(wrapper, "_agent", None) + if hasattr(wrapper, "_agent") + else wrapper + ) + details.append( + AgentDetails( + name=str(name), + description=description, + config=config, + # Expose initialized tools immediately without invoking schema + # generation; the selected tab enriches their arguments in its + # off-thread hydration worker. + tools=_configured_tools( + actual, + getattr(wrapper, "tool_sources", {}) or {}, + include_schema=False, + ) + if actual is not None + else (), + tools_loaded=False, + ) + ) + return tuple(details) + + +async def load_agent_tools(hitl: Any, name: str) -> tuple[ToolDetails, ...]: + """Instantiate one agent, if needed, and return its display-safe tools.""" + use_agent = getattr(hitl, "use_agent", None) + + async def extract(wrapper: Any) -> tuple[ToolDetails, ...]: + actual = getattr(wrapper, "_agent", None) or wrapper + # Schema conversion can invoke provider/Pydantic schema generation for + # every tool, so it should not block Textual's event loop either. + return await asyncio.to_thread( + _configured_tools, + actual, + getattr(wrapper, "tool_sources", {}) or {}, + ) + + async def snapshot() -> tuple[ToolDetails, ...]: + if callable(use_agent): + async with use_agent(name) as wrapper: + return await extract(wrapper) + return await extract(await hitl.get_agent(name)) + + # Keep the lease-owning operation alive if Textual dismisses its worker: + # the worker can clean up immediately, while this task retains the lease + # until non-cancellable thread work has actually finished. + operation = asyncio.create_task(snapshot()) + try: + return await asyncio.shield(operation) + except asyncio.CancelledError: + operation.add_done_callback(_consume_task_exception) + raise + + +def _consume_task_exception(task: asyncio.Task[Any]) -> None: + if not task.cancelled(): + task.exception() diff --git a/src/ursa/cli/tui/app.py b/src/ursa/cli/tui/app.py new file mode 100644 index 00000000..f373c920 --- /dev/null +++ b/src/ursa/cli/tui/app.py @@ -0,0 +1,838 @@ +# ruff: noqa: TID251 + +"""Textual front end for URSA's human-in-the-loop runner.""" + +from __future__ import annotations + +import asyncio +import os +import re +import sys +import threading +import traceback +from collections.abc import Callable, Iterable, Mapping +from dataclasses import asdict, is_dataclass +from enum import Enum +from math import ceil +from pathlib import Path +from typing import Any, ClassVar + +import yaml +from pydantic import BaseModel, SecretStr +from rich.console import Console +from rich.markdown import Markdown as RichMarkdown +from rich.text import Text +from textual import on +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.containers import VerticalScroll +from textual.widget import Widget +from textual.widgets import Static, TextArea + +from ursa.cli.callbacks import HITLLogEventHandler +from ursa.cli.runtime import HITL +from ursa.cli.tui.agent_info import load_agent_details +from ursa.cli.tui.event_handler import TextualEventHandler +from ursa.cli.tui.helpers import ( + COMMAND_CHOICES, + TokenUsage, + _route_prompt, +) +from ursa.cli.tui.themes import AVAILABLE_THEMES +from ursa.cli.tui.turn import Turn +from ursa.cli.tui.widgets import ( + AgentsScreen, + HotlistScreen, + InformationScreen, + MessageCard, + ModelScreen, + ModelSelection, + PromptArea, + ThemeScreen, + ToolMessage, + WelcomeBanner, +) +from ursa.util import crossplatform +from ursa.util import mcp as ursa_mcp + + +def _config_yaml_value(value: Any) -> Any: + """Convert resolved config values to safe, YAML-serializable values.""" + if isinstance(value, SecretStr): + return str(value) + if isinstance(value, BaseModel): + return _config_yaml_value( + value.model_dump(mode="python", context={"include_defaults": True}) + ) + if is_dataclass(value) and not isinstance(value, type): + return _config_yaml_value(asdict(value)) + if isinstance(value, Mapping): + return {str(key): _config_yaml_value(item) for key, item in value.items()} + if isinstance(value, (list, tuple, set)): + return [_config_yaml_value(item) for item in value] + if isinstance(value, (Path, Enum)): + return str(value.value if isinstance(value, Enum) else value) + if value is None or isinstance(value, (str, int, float, bool)): + return value + if hasattr(value, "__dict__"): + return { + key: _config_yaml_value(item) + for key, item in vars(value).items() + if not key.startswith("_") + } + return str(value) + + +class ConversationScroll(VerticalScroll): + """Conversation viewport that reports user-initiated anchor releases.""" + + def __init__( + self, + *children: Widget, + on_release: Callable[[], None], + **kwargs: Any, + ) -> None: + self._on_release = on_release + super().__init__(*children, **kwargs) + + def release_anchor(self) -> None: + super().release_anchor() + self._on_release() + + +class UrsaTextualApp(App[None]): + """Full-screen URSA chat application.""" + + TITLE = "URSA" + SUB_TITLE = "Textual HITL" + BINDINGS: ClassVar = [ + Binding( + "ctrl+c", + "cancel_agent", + "Explain active-turn cancellation", + show=False, + ), + Binding( + "super+c,ctrl+shift+c", + "copy_text", + "Copy selected text", + show=True, + priority=True, + ), + Binding( + "ctrl+d", + "hard_quit", + "Abruptly quit URSA", + show=False, + priority=True, + ), + Binding( + "ctrl+o", "toggle_card_details", "Toggle card details", show=True + ), + Binding( + "ctrl+l", "clear_conversation", "Clear conversation", show=True + ), + Binding("ctrl+q", "quit", "Quit gracefully", show=True, priority=True), + Binding( + "alt+up", + "previous_turn_marker", + "Previous turn marker", + show=False, + priority=True, + ), + Binding( + "alt+down", + "next_turn_marker", + "Next turn marker", + show=False, + priority=True, + ), + ] + CSS_PATH = Path(__file__).with_name("app.tcss") + + def __init__(self, hitl: HITL) -> None: + super().__init__() + self.kitty_keyboard_expected = crossplatform.expects_kitty_keyboard() + for theme in AVAILABLE_THEMES: + self.register_theme(theme) + self.theme = AVAILABLE_THEMES[0].name + self.hitl = hitl + self.total_tokens = 0 + self.input_tokens = 0 + self.output_tokens = 0 + self.cached_tokens = 0 + self.card_details_expanded = False + self.current_turn: Turn | None = None + self._hotlist_open = False + self._hotlist_origin: tuple[str, tuple[int, int]] | None = None + self._ui_thread_id: int | None = None + self._turn_navigation_marker: Widget | None = None + self._quit_after_turn = False + self._conversation_anchor_started = False + self._conversation_anchor_transition = False + self._conversation_anchor_generation = 0 + + def compose(self) -> ComposeResult: + yield ConversationScroll( + WelcomeBanner(self.hitl), + id="conversation", + on_release=self._cancel_conversation_anchor_transition, + ) + yield PromptArea() + yield Static(id="status") + + def on_mount(self) -> None: + self._ui_thread_id = threading.get_ident() + self._update_status("ready") + self.query_one(PromptArea).focus() + + def copy_to_clipboard(self, text: str) -> None: + """Copy text using a platform clipboard tool, falling back to OSC52.""" + self._clipboard = text + if not crossplatform.copy_to_clipboard(text): + super().copy_to_clipboard(text) + + def action_copy_text(self) -> None: + """Copy from the focused editor or the current screen selection.""" + focused = self.focused + if isinstance(focused, TextArea) and focused.selected_text: + focused.action_copy() + else: + self.screen.action_copy_text() + + def on_resize(self) -> None: + self.call_after_refresh(self._resize_prompt, self.query_one(PromptArea)) + self.call_after_refresh(self._anchor_conversation_if_overflowing) + + async def on_unmount(self) -> None: + """Release runtime resources on every graceful Textual shutdown.""" + await self.hitl.aclose() + + @property + def is_ui_thread(self) -> bool: + return threading.get_ident() == self._ui_thread_id + + @property + def preferred_newline_key(self) -> str: + """Return the newline chord most likely to work in this terminal.""" + return "shift+enter" if self.kitty_keyboard_expected else "ctrl+j" + + def _update_status(self, state: str) -> None: + items = [ + self.hitl.config.llm_model.pretty_repr(short=True), + f"{self.total_tokens} tokens", + ] + if agent_name := self.hitl.config.agent_name: + items.append(f"agent {agent_name}") + items.append(state) + self.query_one("#status", Static).update(Text(" • ".join(items))) + + def add_tokens(self, usage: TokenUsage) -> None: + self.total_tokens += usage.total_tokens + self.input_tokens += usage.input_tokens + self.output_tokens += usage.output_tokens + self.cached_tokens += usage.cached_tokens + self._update_status("working") + + async def add_turn_event( + self, + turn: Turn, + data: Mapping[str, Any], + ) -> None: + """Add an event without disturbing a user who has scrolled up.""" + await turn.event(data) + self.call_after_refresh(self._anchor_conversation_if_overflowing) + + def _anchor_conversation_if_overflowing(self) -> None: + """Smoothly reach the bottom, then follow subsequent content.""" + conversation = self.query_one("#conversation", VerticalScroll) + if ( + not self._conversation_anchor_started + and conversation.max_scroll_y > 0 + ): + self._conversation_anchor_started = True + self._conversation_anchor_transition = True + self._conversation_anchor_generation += 1 + generation = self._conversation_anchor_generation + # Start the animation now rather than after the next refresh: + # a user scroll landing in that gap could not stop an animation + # that had not begun, and the late-starting animation then + # overrode the user's position. + conversation.scroll_end( + animate=True, + duration=0.15, + immediate=True, + on_complete=lambda: self._finish_conversation_anchor( + generation + ), + ) + + def _cancel_conversation_anchor_transition(self) -> None: + """Invalidate a pending anchor when scrolling interrupts it.""" + if self._conversation_anchor_transition: + self._conversation_anchor_transition = False + self._conversation_anchor_generation += 1 + + def _finish_conversation_anchor(self, generation: int) -> None: + """Anchor only if the initiating transition is still current.""" + if generation != self._conversation_anchor_generation: + return + self._conversation_anchor_transition = False + self.query_one("#conversation", VerticalScroll).anchor() + + def _reset_conversation_auto_follow( + self, conversation: VerticalScroll + ) -> None: + """Invalidate pending work and allow a fresh anchor transition.""" + self._conversation_anchor_generation += 1 + self._conversation_anchor_transition = False + self._conversation_anchor_started = False + conversation.anchor(False) + + @on(PromptArea.Submitted) + async def submit_prompt(self, event: PromptArea.Submitted) -> None: + prompt_widget = self.query_one(PromptArea) + prompt_widget.load_text("") + turn = Turn(event.text, self.hitl.workspace) + conversation = self.query_one("#conversation", VerticalScroll) + self._reset_conversation_auto_follow(conversation) + await conversation.mount(turn) + self.call_after_refresh(self._anchor_conversation_if_overflowing) + turn.set_card_details_expanded(self.card_details_expanded) + self.current_turn = turn + self._turn_navigation_marker = turn.query_one(".events") + self._update_status("working") + prompt_widget.disabled = True + self.run_worker( + self._run_agent(turn, event.text), exclusive=True, group="agent" + ) + + async def _run_agent(self, turn: Turn, prompt: str) -> None: + name, prompt = self._route_prompt(prompt) + handler = TextualEventHandler(self, turn) + succeeded = True + try: + response = await self.hitl.run_agent( + name, prompt, callbacks=[handler] + ) + except Exception as exc: + succeeded = False + await turn.add_exception( + exc, "".join(traceback.format_exception(exc)) + ) + response = f"**Agent failed:** `{type(exc).__name__}: {exc}`" + turn.finish_activity(succeeded=succeeded) + await turn.add_response(response) + self.call_after_refresh(self._anchor_conversation_if_overflowing) + self._turn_navigation_marker = list(turn.query(MessageCard))[-1] + prompt_widget = self.query_one(PromptArea) + prompt_widget.disabled = False + prompt_widget.focus() + self._update_status("ready") + if self._quit_after_turn: + self.exit() + + def _route_prompt(self, prompt: str) -> tuple[str, str]: + return _route_prompt(self.hitl, prompt) + + def action_cancel_agent(self) -> None: + """Explain that an active agent cannot be cancelled safely.""" + prompt = self.query_one(PromptArea) + if prompt.disabled: + self.notify( + "Cancelling an active turn is not supported. " + "Press Ctrl+D to abruptly quit URSA.", + title="Turn is still running", + severity="warning", + ) + + def action_hard_quit(self) -> None: + """Abruptly terminate URSA without waiting for active work.""" + self.exit(130) + + def action_quit(self) -> None: + """Quit after the active turn, or immediately when idle.""" + if self.query_one(PromptArea).disabled: + self._quit_after_turn = True + self.notify( + "URSA will quit when the active turn finishes. " + "Press Ctrl+D to quit immediately.", + title="Waiting for active turn", + severity="information", + ) + return + self.exit() + + @on(TextArea.Changed, "#prompt") + def prompt_changed(self, event: TextArea.Changed) -> None: + """Resize for all edits without treating programmatic edits as macros.""" + prompt = event.text_area + self.call_after_refresh(self._resize_prompt, prompt) + + @on(PromptArea.MacroTyped) + def macro_typed(self, event: PromptArea.MacroTyped) -> None: + """Open a picker only for a macro character typed by the user.""" + if self._hotlist_open: + return + row, column = event.location + if event.trigger == "/" and (row, column) != (0, 0): + return + self._hotlist_origin = (event.trigger, event.location) + self._hotlist_open = True + self.call_after_refresh(self._open_hotlist, event.trigger) + + def _resize_prompt(self, prompt: TextArea) -> None: + """Fit the prompt to its visual lines within 30% of the terminal.""" + max_content_height = ceil(self.size.height * 0.3) + content_height = min( + max_content_height, max(1, prompt.virtual_size.height) + ) + prompt.styles.height = content_height + 2 + + def _open_hotlist(self, trigger: str) -> None: + candidates = self._hotlist_candidates(trigger) + title = { + "#": "Agents", + "@": "Workspace paths", + "/": "Commands", + }[trigger] + self.push_screen( + HotlistScreen(title, candidates), + callback=lambda choice: self._insert_hotlist_choice( + trigger, choice + ), + ) + + def _insert_hotlist_choice(self, trigger: str, choice: str | None) -> None: + prompt = self.query_one(PromptArea) + origin = self._hotlist_origin + if trigger == "/": + self._hotlist_open = False + self._hotlist_origin = None + if choice: + if origin is not None: + _, start = origin + prompt.replace("", start, (start[0], start[1] + 1)) + self.call_after_refresh( + self._show_command, choice.split(" — ", 1)[0] + ) + else: + prompt.focus() + return + if trigger == "#": + self._insert_agent_choice(choice) + return + if choice and origin is not None: + _, start = origin + prompt.replace( + f"{trigger}{choice} ", + start, + (start[0], start[1] + 1), + ) + self._hotlist_open = False + self._hotlist_origin = None + prompt.focus() + + @staticmethod + def _cursor_offset(text: str, location: tuple[int, int]) -> int: + row, column = location + lines = text.split("\n") + return sum(len(line) + 1 for line in lines[:row]) + column + + @staticmethod + def _offset_location(text: str, offset: int) -> tuple[int, int]: + before = text[:offset] + return before.count("\n"), len(before.rsplit("\n", 1)[-1]) + + def _insert_agent_choice(self, choice: str | None) -> None: + prompt = self.query_one(PromptArea) + origin = self._hotlist_origin + + if choice is not None and origin is not None: + _, trigger_location = origin + original_text = prompt.text + trigger_offset = self._cursor_offset( + original_text, trigger_location + ) + text_without_trigger = ( + original_text[:trigger_offset] + + original_text[trigger_offset + 1 :] + ) + existing = re.match(r"^#[^\s]+[ \t]*", text_without_trigger) + prefix_end = existing.end() if existing else 0 + body = text_without_trigger[prefix_end:] + body_offset = max(0, trigger_offset - prefix_end) + prefix = f"#{choice} " + result = prefix + body + result_location = self._offset_location( + result, len(prefix) + body_offset + ) + end = ( + len(prompt.document.lines) - 1, + len(prompt.document.lines[-1]), + ) + prompt.replace( + result, + (0, 0), + end, + maintain_selection_offset=False, + ) + prompt.move_cursor(result_location) + + self._hotlist_origin = None + self._hotlist_open = False + prompt.focus() + + def _hotlist_candidates(self, trigger: str) -> list[str]: + if trigger == "#": + return sorted(self.hitl.agents) + if trigger == "/": + return [ + f"{name} — {description}" + for name, description in COMMAND_CHOICES.items() + ] + workspace = Path(self.hitl.workspace) + ignored = {".git", ".venv", "__pycache__", "node_modules"} + # TODO: Traverse asynchronously and remove the arbitrary result cap; + # large workspaces currently block the UI and stop at 2,000 paths. + paths: Iterable[Path] = ( + workspace.rglob("*") if workspace.exists() else () + ) + candidates: list[str] = [] + for path in paths: + if ignored.intersection(path.parts): + continue + relative = str(path.relative_to(workspace)) + if path.is_dir(): + candidates.append(f"{relative}{os.sep}") + elif path.is_file(): + candidates.append(relative) + if len(candidates) == 2000: + break + return sorted(candidates) + + async def _show_command(self, command: str) -> None: + if command == "exit": + self.action_quit() + return + if command == "agents": + details = load_agent_details(self.hitl) + self.push_screen( + AgentsScreen(details, self.hitl), + callback=lambda _: self.query_one(PromptArea).focus(), + ) + return + if command == "models": + self.push_screen( + ModelScreen( + self.hitl.config.inference_providers, + self.hitl.config.llm_model, + self.hitl.config.emb_model, + ), + callback=self._select_model, + ) + return + if command == "theme": + choices = [ + self.theme, + *( + theme.name + for theme in AVAILABLE_THEMES + if theme.name != self.theme + ), + ] + self.push_screen( + ThemeScreen(choices, initial_theme=self.theme), + callback=self._select_theme, + ) + return + content = { + "status": self._status_markdown, + "keymap": self._keymap_markdown, + }.get(command) + if content is None: + self.query_one(PromptArea).focus() + return + self.push_screen( + InformationScreen( + command.capitalize(), + content(), + config_yaml=( + self._resolved_config_yaml() if command == "status" else None + ), + ), + callback=lambda _: self.query_one(PromptArea).focus(), + ) + + def _resolved_config_yaml(self) -> str: + """Serialize the active, already-resolved runtime configuration.""" + values = _config_yaml_value(self.hitl.config) + return yaml.safe_dump(values, sort_keys=False, allow_unicode=True) + + def _select_theme(self, theme: str | None) -> None: + if theme is not None: + self.theme = theme + self.query_one(PromptArea).focus() + + def _select_model(self, selection: ModelSelection | None) -> None: + if selection is None: + self.query_one(PromptArea).focus() + return + + async def apply() -> None: + prompt = self.query_one(PromptArea) + previous_chat = self.hitl.config.llm_model + previous_embedding = self.hitl.config.emb_model + prompt.disabled = True + self._update_status("switching model") + try: + await self.hitl.reconfigure_models( + selection.chat, + selection.embedding, + ) + except Exception as exc: # noqa: BLE001 + self.notify( + str(exc), + title="Model not changed", + severity="error", + timeout=10, + markup=False, + ) + else: + for banner in self.query(WelcomeBanner): + banner.refresh_config() + conversation = self.query_one("#conversation", VerticalScroll) + if previous_chat != self.hitl.config.llm_model: + await conversation.mount( + ToolMessage( + f"Changed the chat model to {self.hitl.config.llm_model.pretty_repr()}" + ) + ) + if previous_embedding != self.hitl.config.emb_model: + embedding = self.hitl.config.emb_model + description = ( + embedding.pretty_repr() if embedding else "none" + ) + await conversation.mount( + ToolMessage( + f"Changed the embedding model to {description}" + ) + ) + self.call_after_refresh( + self._anchor_conversation_if_overflowing + ) + finally: + prompt.disabled = False + prompt.focus() + self._update_status("ready") + + self.run_worker(apply(), group="model", exclusive=True) + + def _status_markdown(self) -> str: + emb_model_cfg = self.hitl.config.emb_model + rows = [ + ("Input tokens", f"{self.input_tokens:,}"), + ("Output tokens", f"{self.output_tokens:,}"), + ("Cached tokens", f"{self.cached_tokens:,}"), + ("Total tokens", f"{self.total_tokens:,}"), + ("Theme", self.theme), + ("Workspace", str(Path(self.hitl.workspace).resolve())), + ("Group", str(getattr(self.hitl, "group", None) or "default")), + ("LLM model", self.hitl.config.llm_model.model), + ("LLM Endpoint", self.hitl.config.llm_model.endpoint_repr()), + ( + "Embedding model", + emb_model_cfg.model if emb_model_cfg else "None", + ), + ( + "Embedding Endpoint", + emb_model_cfg.endpoint_repr() if emb_model_cfg else "None", + ), + ] + if agent_name := getattr(self.hitl, "agent_name", None): + rows.insert(3, ("Agent", str(agent_name))) + model_table = "\n".join([ + "| Setting | Value |", + "|---|---|", + *(f"| {key} | `{value}` |" for key, value in rows), + ]) + servers = self.hitl.config.mcp_servers + if not servers: + return model_table + "\n\n## MCP servers\n\nNone configured." + server_rows = [] + for name, server in servers.items(): + location = ( + server.command + if isinstance(server, ursa_mcp.StdioServerParameters) + else server.url + ) + server_rows.append( + f"| `{name}` | {ursa_mcp.transport(server)} | `{location}` |" + ) + return ( + model_table + + "\n\n## MCP servers\n\n" + + "\n".join([ + "| Name | Transport | Location |", + "|---|---|---|", + *server_rows, + ]) + ) + + @staticmethod + def _effective_bindings(owner: type[Any]) -> list[Binding]: + """Collect Textual bindings with subclass definitions taking priority.""" + bindings: dict[str, Binding] = {} + for base in reversed(owner.__mro__): + declared = base.__dict__.get("BINDINGS", ()) + for binding in Binding.make_bindings(declared): + bindings[binding.key] = binding + return list(bindings.values()) + + def _keymap_markdown(self) -> str: + sections = ( + ("Application", type(self)), + ("Prompt editor", PromptArea), + ("Picker", HotlistScreen), + ("Information screen", InformationScreen), + ) + priority_keys = { + binding.key + for binding in self._effective_bindings(type(self)) + if binding.priority + } + compatibility = ( + "> **Terminal support:** Kitty keyboard support expected for " + "this terminal." + if self.kitty_keyboard_expected is True + else "> **Terminal support:** Kitty keyboard support not " + "identified; some modified keys may not work." + ) + output = [ + compatibility, + "", + ] + for title, owner in sections: + actions: dict[tuple[str, str], list[str]] = {} + for binding in self._effective_bindings(owner): + if binding.system or not binding.description: + continue + if owner is not type(self) and binding.key in priority_keys: + continue + identity = (binding.action, binding.description) + actions.setdefault(identity, []).append( + self.get_key_display(binding) + ) + output.extend([ + f"## {title}", + "", + "| Key | Action |", + "|---|---|", + *( + f"| `{' / '.join(keys)}` | {description} |" + for (_, description), keys in actions.items() + ), + "", + ]) + return "\n".join(output).rstrip() + + def action_toggle_card_details(self) -> None: + self.card_details_expanded = not self.card_details_expanded + for turn in self.query(Turn): + turn.set_card_details_expanded(self.card_details_expanded) + + def _turn_markers(self) -> list[Widget]: + markers: list[Widget] = [] + for turn in self.query(Turn): + messages = list(turn.query(MessageCard)) + if not messages: + continue + activity = turn.query_one(".events") + markers.extend((messages[0], activity)) + if len(messages) > 1: + markers.append(messages[-1]) + markers.append(turn.query_one(".turn-end-marker")) + return markers + + def _navigate_turn_markers(self, offset: int) -> None: + markers = self._turn_markers() + if not markers: + return + try: + index = markers.index(self._turn_navigation_marker) + except ValueError: + index = len(markers) if offset < 0 else -1 + target_index = max(0, min(len(markers) - 1, index + offset)) + target = markers[target_index] + self._turn_navigation_marker = target + conversation = self.query_one("#conversation", VerticalScroll) + if target_index == len(markers) - 1: + conversation.anchor() + return + target_y = target.virtual_region.y + ancestor = target.parent + while ancestor is not None and ancestor is not conversation: + target_y += ancestor.virtual_region.y + ancestor = ancestor.parent + conversation.scroll_to( + y=max(0, target_y), + animate=False, + immediate=True, + force=True, + release_anchor=True, + ) + + def action_previous_turn_marker(self) -> None: + self._navigate_turn_markers(-1) + + def action_next_turn_marker(self) -> None: + self._navigate_turn_markers(1) + + async def action_clear_conversation(self) -> None: + if self.query_one(PromptArea).disabled: + self.notify( + "Clearing the conversation is not allowed while a turn is " + "active. Press Ctrl+D to abruptly quit URSA.", + title="Turn is still running", + severity="warning", + ) + return + conversation = self.query_one("#conversation", VerticalScroll) + self._reset_conversation_auto_follow(conversation) + await conversation.remove_children() + await conversation.mount(WelcomeBanner(self.hitl)) + conversation.scroll_home(animate=False) + self._turn_navigation_marker = None + + +def run_textual(hitl: HITL) -> None: + """Launch the experimental full-screen interface.""" + try: + UrsaTextualApp(hitl).run() + finally: + asyncio.run(hitl.aclose()) + + +def run_textual_once(hitl: HITL, prompt: str, *, stdout: Any = None) -> str: + """Run one routed prompt and render its event stream to standard output.""" + output = stdout or sys.stdout + console = Console(file=output) + handler = HITLLogEventHandler(console=console, workspace=hitl.workspace) + agent, routed_prompt = _route_prompt(hitl, prompt) + + async def invoke() -> str: + try: + return await hitl.run_agent( + agent, routed_prompt, callbacks=[handler] + ) + finally: + await hitl.aclose() + + response = asyncio.run(invoke()) + if handler.emitted_any: + console.print() + if console.is_terminal: + console.print(RichMarkdown(response)) + else: + print(response, file=output) # noqa: T201 + return response diff --git a/src/ursa/cli/tui/app.tcss b/src/ursa/cli/tui/app.tcss new file mode 100644 index 00000000..461654a3 --- /dev/null +++ b/src/ursa/cli/tui/app.tcss @@ -0,0 +1,659 @@ +Screen { + layout: vertical; + background: $background; +} + +HotlistScreen, +ThemeScreen { + align: center bottom; + background: transparent; +} + +#hotlist { + width: 100%; + height: 65%; + min-height: 10; + max-height: 16; + margin-bottom: 4; + padding: 0 1 1 1; + border: round $accent; + background: $surface; +} + +#hotlist-header { + width: 100%; + height: 1; +} + +#hotlist-title { + width: 1fr; +} + +#hotlist-exit-hint { + width: auto; + content-align: right middle; + color: $text-muted; + text-style: italic; +} + +#hotlist-query { + margin-bottom: 0; +} + +#hotlist-options { + height: 1fr; + min-height: 3; +} + +InformationScreen { + align: center middle; + background: $background 50%; +} + +ModelScreen { + align: center middle; + background: $background 50%; +} + +.settings-dialog { + width: 70; + height: auto; + padding: 1 2; + border: round $accent; + background: $surface; +} + +ModelScreen .settings-dialog { + height: auto; + max-height: 100%; + overflow-y: auto; +} + +ModelScreen TabbedContent, +ModelScreen TabPane { + height: auto; +} + +ModelScreen .model-advanced { + margin-top: 1; + padding: 0; + border: none; + background: transparent; +} + +ModelScreen .model-advanced > CollapsibleTitle { + height: 1; + padding: 0; + color: $text-muted; + background: transparent; +} + +ModelScreen .model-yaml-editor { + height: 12; + margin-top: 1; + border: round $border; +} + +ModelScreen .model-yaml-editor.yaml-valid { + border: round $success; +} + +ModelScreen .model-yaml-editor.yaml-invalid { + border: round $error; +} + +ModelScreen .model-yaml-error { + width: 1fr; + height: auto; + max-height: 6; + color: $error; + text-wrap: wrap; + overflow-x: hidden; + overflow-y: auto; +} + +ModelScreen Input, +ModelScreen Select, +ModelScreen SelectCurrent { + height: 1; + border: none; +} + +ModelScreen Input, +ModelScreen SelectCurrent { + padding: 0 1; +} + +.model-field-label { + width: 1fr; + height: 2; + padding-top: 1; +} + +.model-field-label-text, +.model-field-help-bracket, +.model-field-help-mark { + width: auto; + height: 1; +} + +.model-field-label-text { + margin-right: 1; +} + +.model-field-help-mark { + color: $accent; +} + +.settings-title { + height: 2; + text-style: bold; + color: $accent; +} + +.settings-actions { + height: 2; + align-horizontal: right; + padding-top: 1; +} + +.settings-actions Button { + width: auto; + min-width: 10; + height: 1; + padding: 0 1; + border: none; + margin-left: 1; +} + +#information { + width: 90%; + height: 85%; + padding: 1 2; + border: round $accent; + background: $surface; +} + +#information-title { + height: 2; + text-style: bold; + color: $accent; +} + +#information-body { + height: 1fr; +} + +#status-tabs, #status-summary-tab, #status-config-tab { + height: 1fr; +} + +#status-config-readonly { + height: 1; + color: $text-muted; + text-style: italic; +} + +#status-config-yaml { + height: 1fr; + border: round $accent; +} + +#agents-tabs { + height: 1fr; +} + +.agent-details { + height: 1fr; + padding: 0 1; +} + +.agent-tools-title { + height: 2; + padding-top: 1; + text-style: bold; + color: $accent; +} + +.agent-tools { + height: auto; +} + +.agent-tool { + height: auto; +} + +.agent-tools-error { + height: auto; + color: $error; +} + +.agent-tools-empty { + height: 1; + color: $text-muted; +} + +.agent-tools-loading { + height: 1; + color: $text-muted; +} + +#conversation { + height: 1fr; + padding: 1 2; + overflow-y: scroll; + scrollbar-size-vertical: 1; + scrollbar-color: $scrollbar; + scrollbar-color-hover: $scrollbar-hover; + scrollbar-color-active: $scrollbar-active; + scrollbar-background: $scrollbar-background; +} + +#welcome { + height: auto; + margin-bottom: 0; +} + +#welcome-top { + height: 8; +} + +#welcome-logo { + width: 1fr; + height: 8; + padding: 0 3; + border: round $border; + color: $text; + align: center middle; +} + +#welcome-logo-stack { + width: 100%; + height: 5; +} + +#welcome-logo-art { + height: 4; + content-align: center middle; +} + +#welcome-version { + height: 1; + content-align: right middle; + overflow: hidden hidden; +} + +#welcome-config { + width: 1fr; + height: 8; + padding: 0 2; + border: round $border; + color: $text; +} + +#welcome-workspace-row { + width: 100%; + margin-top: 1; +} + +#welcome-workspace-row.workspace-inline { + layout: horizontal; + height: 1; +} + +#welcome-workspace-row.workspace-stacked { + layout: vertical; + height: 2; +} + +#welcome-workspace-row.workspace-inline > #welcome-workspace-label { + width: 11; +} + +#welcome-workspace-row.workspace-inline > #welcome-workspace { + width: 1fr; + content-align: left middle; +} + +#welcome-workspace-row.workspace-stacked > #welcome-workspace-label, +#welcome-workspace-row.workspace-stacked > #welcome-workspace { + width: 100%; +} + +#welcome-workspace-row.workspace-stacked > #welcome-workspace { + content-align: right middle; +} + +#welcome-workspace-label { + height: 1; +} + +#welcome-workspace { + height: 1; + overflow: hidden hidden; + text-overflow: ellipsis; +} + +#welcome-config-values { + height: 3; +} + +#welcome-tip { + height: 3; + padding: 0 2; + border: round $border; + color: $text-muted; + content-align: left middle; +} + +.turn { + height: auto; + margin-bottom: 0; +} + +.turn-end-marker { + height: 0; +} + +.message-card { + height: auto; + padding: 0 2; + margin-bottom: 0; +} + +.message-card.user { + margin-top: 1; + margin-bottom: 0; + background: $primary-background; + border-left: thick $primary; +} + +.message-card.assistant { + margin-top: 1; + background: $secondary-background; + border-left: thick $secondary; +} + +.message-role { + height: 1; + color: $text-muted; + text-style: bold; +} + +.message-body { + height: auto; + background: transparent; +} + +.events { + height: auto; + margin: 0; + padding-left: 1; + border-left: thick $warning-muted; + background: $panel; +} + +.events.has-events { + margin-top: 1; +} + +.event-card { + height: auto; + padding: 0; + margin-bottom: 0; + background: $surface; + color: $text; +} + +.event-summary { + height: auto; + background: transparent; +} + +.event-card-done { + display: none; + dock: right; + width: 2; + height: 100%; + content-align: right bottom; + color: $primary; +} + +.event-card.summary-done > .event-card-done { + display: block; +} + +.exception-traceback { + height: auto; + margin: 0 1; + border: round $border; + background: $surface; +} + +.exception-card > .event-expand-hint { + dock: none; + width: 100%; + content-align: right middle; +} + +.event-expand-hint { + dock: right; + width: 18; + height: 1; + content-align: right middle; + color: $text-muted; + text-style: italic; +} + +.tool-call-title { + height: 1; + padding: 0 1; + text-style: bold; +} + +.tool-call-summary { + height: 1; + padding: 0 1; +} + +.tool-call-state { + width: 4; + color: $primary; +} + +.tool-call-preview { + width: 1fr; + height: 1; + overflow: hidden hidden; + text-overflow: ellipsis; +} + +.tool-call-details { + height: auto; + padding: 0 1; +} + +.tool-json-pane { + width: 1fr; + height: auto; + margin: 0 1 0 0; + border: round $border; +} + +.tool-output-pane { + margin: 0 0 0 1; +} + +.tool-json-title { + height: 1; + padding: 0 1; + color: $text-muted; + text-style: bold; +} + +.tool-json { + height: auto; + padding: 0 1; + background: $surface; +} + +.tool-output-markdown { + height: auto; + padding: 0 1; + background: transparent; +} + +.edit-group-title { + height: 1; + padding: 0 1; + text-style: bold; +} + +.edit-header { + height: 1; + padding: 0 1; +} + +.edit-title { + width: 1fr; + height: 1; + overflow: hidden hidden; + text-overflow: ellipsis; +} + +.edit-counts { + width: auto; + height: 1; + margin-right: 1; +} + +.edit-hint { + width: 18; + height: 1; + content-align: right middle; + color: $text-muted; + text-style: italic; +} + +.edit-outcome { + height: 1; + padding: 0 1; +} + +.edit-diff { + margin-left: 2; + border: round $border; + background: $surface; +} + +.command-source, +.command-output { + height: auto; + padding: 0 1; +} + +.command-compact { + height: 1; + padding: 0 1; +} + +.command-compact-state { + width: 3; + color: $primary; +} + +.command-compact-text { + width: 1fr; +} + +RunCommandCard > .activity { + margin: 0; + padding-left: 1; + border-left: none; + background: $surface; +} + +RunCommandCard.command-expanded { + border: round $border; +} + +RunCommandCard.command-expanded > .event-expand-hint { + dock: bottom; + width: 100%; + padding-right: 1; +} + +.hidden { + display: none; +} + +#prompt { + height: 3; + min-height: 3; + margin: 0 1; + border: tall $border-blurred; + background: $surface; +} + +#prompt:focus { + border: tall $border; +} + +.activity { + height: 1; + margin: 0; + padding-left: 1; + border-left: thick $warning-muted; + background: $panel; + color: $text-muted; +} + +.tool-message { + height: 1; + margin: 1 2 0 2; + color: $text-muted; +} + +.tool-message-mark { + width: 3; + color: $primary; +} + +.tool-message-body { + width: 1fr; +} + +.activity.activity-after-user { + margin-top: 1; +} + +.activity-spinner { + width: 3; + color: $primary; +} + +.activity-text { + width: 1fr; +} + +.activity-done-mark { + display: none; + width: 1; + color: $primary; + content-align: right bottom; +} + +.activity.done > .activity-text { + content-align: right middle; +} + +.activity.done > .activity-done-mark { + display: block; +} + +#status { + height: 1; + padding: 0 2; + background: $panel; + color: $text; +} diff --git a/src/ursa/cli/tui/event_cards/__init__.py b/src/ursa/cli/tui/event_cards/__init__.py new file mode 100644 index 00000000..67855447 --- /dev/null +++ b/src/ursa/cli/tui/event_cards/__init__.py @@ -0,0 +1,17 @@ +"""Event card widgets used by the Textual conversation view.""" + +from ursa.cli.tui.event_cards.agents import AgentEventCard as AgentEventCard +from ursa.cli.tui.event_cards.artifacts import ArtifactCard as ArtifactCard +from ursa.cli.tui.event_cards.base import EventCard as EventCard +from ursa.cli.tui.event_cards.base import ExceptionCard as ExceptionCard +from ursa.cli.tui.event_cards.commands import ( + CommandSafetyIndicator as CommandSafetyIndicator, +) +from ursa.cli.tui.event_cards.commands import ( + RunCommandCard as RunCommandCard, +) +from ursa.cli.tui.event_cards.files import EditCard as EditCard +from ursa.cli.tui.event_cards.files import FileActivityCard as FileActivityCard +from ursa.cli.tui.event_cards.plan import PlanCard as PlanCard +from ursa.cli.tui.event_cards.search import SearchEventCard as SearchEventCard +from ursa.cli.tui.event_cards.tools import ToolCallCard as ToolCallCard diff --git a/src/ursa/cli/tui/event_cards/agents.py b/src/ursa/cli/tui/event_cards/agents.py new file mode 100644 index 00000000..0169bc08 --- /dev/null +++ b/src/ursa/cli/tui/event_cards/agents.py @@ -0,0 +1,81 @@ +"""Agent progress event cards.""" + +from collections.abc import Mapping +from typing import Any + +from ursa.cli.tui.event_cards.base import EventCard +from ursa.cli.tui.event_cards.files import EditCard +from ursa.cli.tui.helpers import AGENT_LABELS + + +class AgentEventCard(EventCard): + """Specialized live summary for non-file agent progress.""" + + def __init__(self, key: str, agent: str) -> None: + icon, label = AGENT_LABELS.get(agent, ("◌", agent or "Agent")) + super().__init__(key, f"{icon} {label}") + + @staticmethod + def _stage_icon(agent: str, stage: str, payload: Mapping[str, Any]) -> str: + if agent in {"PlanningAgent", "planner"}: + if stage == "reflect_result": + return "✅" if payload.get("approved") else "🔁" + return {"generate": "📐", "generate_result": "🗺️"}.get(stage, "📋") + if agent in {"HypothesizerAgent", "hypothesizer"}: + return { + "generate": "✨", + "generate_result": "💡", + "critique": "🔬", + "critique_result": "🧪", + "competitor": "🧭", + "competitor_result": "🗣️", + "finalize": "🛠️", + "finalize_result": "⭐", + "summarize": "📝", + "summarize_result": "📚", + }.get(stage, "💡") + if agent in {"LammpsAgent", "lammps"}: + return { + "author_input": "📝", + "choose_potential": "🧲", + "fix_input": "🛠️", + "run": "▶", + "run_result": ("✅" if payload.get("returncode") == 0 else "✖"), + "summarize_potential": "🔬", + "summarize_results": "📊", + }.get(stage, "⚛️") + return "⚙️" + + def update_event(self, payload: Mapping[str, Any]) -> None: + agent = str(payload.get("agent") or "") + message = str(payload.get("message") or payload.get("stage") or "Event") + stage = str(payload.get("stage") or "") + detail = payload.get("preview") + if stage == "reflect_result": + detail = payload.get("reason") + elif stage == "choose_potential" and payload.get("phase") == "end": + detail = "\n".join( + filter( + None, + ( + f"Potential: {payload.get('potential_id')}", + f"Index: {payload.get('chosen_index')}", + str(payload.get("rationale") or ""), + ), + ) + ) + elif stage == "run" and payload.get("phase") == "error": + detail = payload.get("error_output") or payload.get("error") + elif stage == "fix_input" and ( + payload.get("old_code") is not None + or payload.get("new_code") is not None + ): + detail = EditCard._diff( + str(payload.get("old_code") or ""), + str(payload.get("new_code") or ""), + )[2] + if output_path := payload.get("output_path"): + output_detail = f"Output: {output_path}" + detail = f"{detail}\n{output_detail}" if detail else output_detail + icon = self._stage_icon(agent, stage, payload) + self.add(f"{icon} {message}", str(detail) if detail else None) diff --git a/src/ursa/cli/tui/event_cards/artifacts.py b/src/ursa/cli/tui/event_cards/artifacts.py new file mode 100644 index 00000000..5116f39f --- /dev/null +++ b/src/ursa/cli/tui/event_cards/artifacts.py @@ -0,0 +1,32 @@ +"""Structured artifact event cards.""" + +from collections.abc import Mapping +from typing import Any + +from textual.app import ComposeResult +from textual.widgets import Static + +from ursa.cli.tui.event_cards.base import EventCard +from ursa.util.rendering import render_event_artifacts + + +class ArtifactCard(EventCard): + """Rich-rendered structured artifacts emitted by an agent or tool.""" + + def __init__(self, key: str, artifacts: list[Mapping[str, Any]]) -> None: + super().__init__(key, "Artifact") + self.artifacts = artifacts + + def compose(self) -> ComposeResult: + yield Static( + render_event_artifacts(self.artifacts), classes="event-summary" + ) + yield Static("", classes="event-card-done") + yield Static("Click to expand", classes="event-expand-hint") + + def refresh_content(self) -> None: + """Refresh without assuming the summary widget is Markdown.""" + if self.is_mounted: + self.query_one(".event-summary", Static).update( + render_event_artifacts(self.artifacts) + ) diff --git a/src/ursa/cli/tui/event_cards/base.py b/src/ursa/cli/tui/event_cards/base.py new file mode 100644 index 00000000..9bc51c81 --- /dev/null +++ b/src/ursa/cli/tui/event_cards/base.py @@ -0,0 +1,116 @@ +# ruff: noqa: TID251 + +"""Base class for dynamically updated event cards.""" + +from rich.traceback import Traceback +from textual import events +from textual.app import ComposeResult +from textual.widgets import Markdown, Static + + +class EventCard(Static): + """A live, compact summary of one event stream.""" + + def __init__(self, key: str, label: str) -> None: + super().__init__(classes="event-card") + self.key = key + self.label = label + self.lines: list[str] = [] + self.details: list[str] = [] + self.expanded = False + self.done = False + + def compose(self) -> ComposeResult: + yield Markdown("", classes="event-summary") + yield Static("", classes="event-card-done") + yield Static("Click to expand", classes="event-expand-hint") + + def on_mount(self) -> None: + self.refresh_content() + self._update_expand_hint() + + def mark_done(self) -> None: + if self.done: + return + self.done = True + self.add_class("summary-done") + markers = list(self.query(".event-card-done")) + if markers: + markers[0].update("✓") + + def add(self, summary: str, detail: str | None = None) -> None: + if summary and summary not in self.lines: + self.lines.append(summary) + if detail: + self.details.append(detail) + self.refresh_content() + + def set_expanded(self, expanded: bool) -> None: + self.expanded = expanded + self.refresh_content() + self._update_expand_hint() + + def _update_expand_hint(self) -> None: + if not self.is_mounted: + return + hints = list(self.query(".event-expand-hint")) + if hints: + hints[0].update( + "Click to collapse" if self.expanded else "Click to expand" + ) + + def on_click(self, event: events.Click) -> None: + event.stop() + self.set_expanded(not self.expanded) + + def refresh_content(self) -> None: + if not self.is_mounted: + return + visible = self.lines if self.expanded else self.lines[-6:] + omitted = len(self.lines) - len(visible) + body = [f"**{self.label}**"] + if omitted: + body.append(f"_{omitted} earlier items hidden_ ") + body.extend(f"- {line}" for line in visible) + if self.expanded: + body.extend(f"\n```text\n{detail}\n```" for detail in self.details) + self.query_one(Markdown).update("\n".join(body)) + + +class ExceptionCard(EventCard): + """An agent failure whose complete traceback is available on expansion.""" + + def __init__(self, key: str, error: BaseException, traceback: str) -> None: + super().__init__(key, "✖ Exception") + self.add_class("exception-card") + self.error = error + self.rich_traceback = Traceback.from_exception( + type(error), + error, + error.__traceback__, + width=None, + code_width=100, + extra_lines=3, + word_wrap=True, + show_locals=False, + max_frames=1000, + ) + self.add(f"{type(error).__name__}: {error}", traceback) + self.mark_done() + + def compose(self) -> ComposeResult: + yield Markdown("", classes="event-summary") + yield Static( + self.rich_traceback, + classes="exception-traceback hidden", + ) + yield Static("", classes="event-card-done") + yield Static("Click to expand", classes="event-expand-hint") + + def refresh_content(self) -> None: + if not self.is_mounted: + return + self.query_one(Markdown).update(f"**{self.label}**\n- {self.lines[0]}") + self.query_one(".exception-traceback").set_class( + not self.expanded, "hidden" + ) diff --git a/src/ursa/cli/tui/event_cards/commands.py b/src/ursa/cli/tui/event_cards/commands.py new file mode 100644 index 00000000..d96428fe --- /dev/null +++ b/src/ursa/cli/tui/event_cards/commands.py @@ -0,0 +1,278 @@ +# ruff: noqa: TID251 + +"""Command execution and safety-check event cards.""" + +from collections.abc import Mapping +from typing import Any + +from rich.syntax import Syntax +from rich.text import Text +from textual.app import ComposeResult +from textual.containers import Horizontal +from textual.widgets import Static + +from ursa.cli.tui.event_cards.base import EventCard +from ursa.cli.tui.widgets import ActivityIndicator + + +class CommandSafetyIndicator(ActivityIndicator): + """Safety-check state for a single command invocation.""" + + def __init__(self) -> None: + super().__init__() + self.status = "pending" + + def compose(self) -> ComposeResult: + yield Static(self.FRAMES[0], classes="activity-spinner") + yield Static("Running safety check", classes="activity-text") + + def passed(self) -> None: + self.status = "passed" + if self._timer is not None: + self._timer.pause() + self.query_one(".activity-spinner", Static).update("✓") + self.query_one(".activity-text", Static).update("Safety check passed") + + def failed(self, reason: str | None = None) -> None: + self.status = "failed" + if self._timer is not None: + self._timer.pause() + self.query_one(".activity-spinner", Static).update("⚔️") + self.query_one(".activity-text", Static).update( + Text(reason or "Safety check failed") + ) + + def unavailable(self) -> None: + self.status = "unavailable" + if self._timer is not None: + self._timer.pause() + self.query_one(".activity-spinner", Static).update("✗") + self.query_one(".activity-text", Static).update( + "Safety check did not complete" + ) + + +class RunCommandCard(EventCard): + """Progressively disclose one command, its safety check, and output.""" + + def __init__(self, key: str, command: str) -> None: + super().__init__(key, "run_command") + self.command = command + self.completed = False + self.multi_command = False + self.output_expanded = False + self._full_output = "" + self.returncode: int | None = None + self.execution_failed = False + self.safety_failed = False + self.force_compact = False + self._compact_frame = 0 + self._compact_timer = None + + def compose(self) -> ComposeResult: + with Horizontal(classes="command-compact hidden"): + yield Static(self.FRAMES[0], classes="command-compact-state") + yield Static( + self._collapsed_command(), classes="command-compact-text" + ) + yield Static( + self._command_syntax(collapsed=False), classes="command-source" + ) + yield CommandSafetyIndicator() + yield Static("", classes="command-output hidden") + yield Static("Click to expand", classes="event-expand-hint") + + def on_mount(self) -> None: + self._compact_timer = self.set_interval( + 0.08, self._advance_compact_spinner, pause=True + ) + self._update_expand_hint() + + @property + def FRAMES(self) -> tuple[str, ...]: + return ActivityIndicator.FRAMES + + def _advance_compact_spinner(self) -> None: + if self.safety_failed: + self.query_one(".command-compact-state", Static).update("⚔️") + return + self.query_one(".command-compact-state", Static).update( + self.FRAMES[self._compact_frame] + ) + self._compact_frame = (self._compact_frame + 1) % len(self.FRAMES) + + @staticmethod + def _preview_command(text: str) -> str: + lines = text.splitlines() + if len(lines) <= 20: + return text + omitted = len(lines) - 16 + return "\n".join([ + *lines[:8], + f"… {omitted} lines omitted …", + *lines[-8:], + ]) + + @staticmethod + def _preview_output(text: str) -> str: + lines = text.splitlines() + if len(lines) <= 10: + return text + omitted = len(lines) - 8 + return "\n".join([ + *lines[:4], + f"… {omitted} lines omitted …", + *lines[-4:], + ]) + + def _collapsed_command(self) -> str: + lines = self.command.splitlines() or [self.command] + command = lines[0] + if len(lines) > 1: + command += " …" + if len(command) > 120: + command = command[:119] + "…" + return command + + def _command_syntax( + self, *, collapsed: bool, expanded: bool = False + ) -> Syntax: + lines = self.command.splitlines() or [self.command] + if expanded: + command = "\n".join(lines) + elif collapsed: + command = self._collapsed_command() + else: + command = self._preview_command("\n".join(lines)) + return Syntax( + command, "bash", word_wrap=True, background_color="default" + ) + + def _render_command(self) -> None: + if not self.is_mounted: + return + self.query_one(".command-source", Static).update( + self._command_syntax( + collapsed=self.completed and not self.output_expanded, + expanded=self.output_expanded, + ) + ) + + def update_event(self, payload: dict[str, Any]) -> None: + stage = str(payload.get("stage") or "") + phase = str(payload.get("phase") or "") + if isinstance(payload.get("returncode"), int): + self.returncode = payload["returncode"] + if phase == "error" or payload.get("status") == "error": + self.execution_failed = True + if stage == "safety_check": + safety = self.query_one(CommandSafetyIndicator) + if payload.get("safe") is True: + safety.passed() + elif payload.get("safe") is False: + self.safety_failed = True + self.force_compact = True + safety.failed(str(payload.get("reason") or "") or None) + + output = payload.get("result") + if output is None and stage == "execute" and phase == "end": + artifacts = payload.get("artifacts") + if isinstance(artifacts, list): + contents = [ + str(artifact.get("content")) + for artifact in artifacts + if isinstance(artifact, Mapping) + and artifact.get("content") not in (None, "") + ] + if contents: + output = "\n".join(contents) + if output is not None or phase == "error": + self.complete( + output, + execution_confirmed=phase == "end" + and not self.execution_failed, + ) + + def complete(self, output: Any, *, execution_confirmed: bool) -> None: + self.completed = True + safety = self.query_one(CommandSafetyIndicator) + if safety.status == "pending" and not self.safety_failed: + if execution_confirmed: + safety.passed() + else: + safety.unavailable() + if self._compact_timer is not None: + self._compact_timer.pause() + self.query_one(".command-compact-state", Static).update( + self._completion_icon() + ) + self._render_command() + self._full_output = self._clean_output(output) + if not self._full_output: + self.force_compact = True + self._render_output() + self._update_visibility() + + def _completion_icon(self) -> str: + if self.safety_failed: + return "⚔️" + if self.execution_failed or ( + self.returncode is not None and self.returncode != 0 + ): + return "✗" + return "✓" + + def set_multi_command(self, multi_command: bool) -> None: + self.multi_command = multi_command + if self._compact_timer is not None: + if multi_command and not self.completed and not self.safety_failed: + self._advance_compact_spinner() + self._compact_timer.resume() + else: + self._compact_timer.pause() + self._update_visibility() + + def set_output_expanded(self, expanded: bool) -> None: + self.expanded = expanded + self.output_expanded = expanded + self.set_class(expanded, "command-expanded") + self._render_command() + self._render_output() + self._update_visibility() + self._update_expand_hint() + + def _render_output(self) -> None: + if not self.completed: + return + output = ( + self._full_output + if self.output_expanded + else self._preview_output(self._full_output) + ) + output = output or "(no output)" + self.query_one(".command-output", Static).update( + Syntax(output, "text", word_wrap=True, background_color="default") + ) + + def _update_visibility(self) -> None: + if not self.is_mounted: + return + compact = self.multi_command and not self.output_expanded + self.query_one(".command-compact").set_class(not compact, "hidden") + self.query_one(".command-source").set_class(compact, "hidden") + self.query_one(CommandSafetyIndicator).set_class(compact, "hidden") + show_output = self.completed and not compact + self.query_one(".command-output").set_class(not show_output, "hidden") + + @staticmethod + def _clean_output(output: Any) -> str: + text = str(output or "") + if text.startswith("STDOUT:\n") and "\nSTDERR:\n" in text: + stdout, stderr = text[len("STDOUT:\n") :].split("\nSTDERR:\n", 1) + if stdout and stderr: + return f"STDOUT:\n{stdout}\nSTDERR:\n{stderr}" + text = stdout or stderr + return text.rstrip() + + def set_expanded(self, expanded: bool) -> None: + self.set_output_expanded(expanded) diff --git a/src/ursa/cli/tui/event_cards/files.py b/src/ursa/cli/tui/event_cards/files.py new file mode 100644 index 00000000..fc927567 --- /dev/null +++ b/src/ursa/cli/tui/event_cards/files.py @@ -0,0 +1,176 @@ +# ruff: noqa: TID251 + +"""File access and editing event cards.""" + +from pathlib import Path + +from rich.syntax import Syntax +from rich.text import Text +from textual.app import ComposeResult +from textual.containers import Horizontal +from textual.widgets import Static + +from ursa.cli.tui.event_cards.base import EventCard + + +class FileActivityCard(EventCard): + """Group touched files by the operation performed on them.""" + + SECTIONS = ("Reading", "Editing") + + def __init__(self, key: str = "files") -> None: + super().__init__(key, "◫ Files") + self.files: dict[str, dict[str, tuple[int | None, int | None]]] = { + section: {} for section in self.SECTIONS + } + self.outcomes: dict[tuple[str, str], tuple[str, str]] = {} + + def compose(self) -> ComposeResult: + yield Static("", classes="event-summary file-summary") + yield Static("", classes="event-card-done") + yield Static("Click to expand", classes="event-expand-hint") + + def add_file( + self, + operation: str, + path: str, + *, + additions: int | None = None, + deletions: int | None = None, + ) -> None: + current = self.files[operation].get(path) + if current is not None and additions is None and deletions is None: + return + self.files[operation][path] = (additions, deletions) + self.refresh_content() + + def record_outcome( + self, operation: str, path: str, state: str, detail: str = "" + ) -> None: + self.outcomes[(operation, path)] = (state, detail) + self.refresh_content() + + def refresh_content(self) -> None: + if not self.is_mounted: + return + output = Text() + reading = self.files["Reading"] + if reading: + output.append("📖 Reading: ", style="bold") + for index, path in enumerate(reading): + if index: + output.append(", ") + output.append(path, style="cyan") + + editing = self.files["Editing"] + if editing: + if reading: + output.append("\n") + output.append("✍️ Editing", style="bold") + path_width = max(map(len, editing)) + addition_width = max( + len(f"+{additions if additions is not None else '?'}") + for additions, _ in editing.values() + ) + for path, (additions, deletions) in editing.items(): + addition = f"+{additions if additions is not None else '?'}" + deletion = f"-{deletions if deletions is not None else '?'}" + output.append("\n- ") + output.append(path, style="cyan") + output.append(" " * (path_width - len(path) + 3)) + output.append(addition.rjust(addition_width), style="green") + output.append(" ") + output.append(deletion, style="red") + for (operation, path), (state, detail) in self.outcomes.items(): + if reading or editing or output: + output.append("\n") + icon = "✖" if state == "failed" else "⚠" + style = "red" if state == "failed" else "yellow" + output.append(f"{icon} {operation} {state}: ", style=style) + output.append(path, style="cyan") + if detail: + output.append(f" — {' '.join(detail.split())}", style="dim") + self.query_one(".file-summary", Static).update(output) + + +class EditCard(EventCard): + def __init__( + self, + path: str, + old: str, + new: str, + *, + show_heading: bool = True, + ) -> None: + super().__init__(f"edit:{path}", f"✎ {path}") + self.path = path + self.show_heading = show_heading + self.additions, self.deletions, self.diff = self._diff(old, new) + self.expanded = False + + def compose(self) -> ComposeResult: + if self.show_heading: + yield Static("✍️ Editing", classes="edit-group-title") + with Horizontal(classes="edit-header"): + yield Static( + Text(f"- {Path(self.path).name}"), classes="edit-title" + ) + counts = Text(f"+{self.additions}", style="green") + counts.append(f" -{self.deletions}", style="red") + yield Static(counts, classes="edit-counts") + yield Static("Click to expand", classes="edit-hint") + yield Static("", classes="edit-outcome hidden") + yield Static("", classes="event-summary edit-diff hidden") + + @staticmethod + def _diff(old: str, new: str) -> tuple[int, int, str]: + import difflib + + lines = list( + difflib.unified_diff( + old.splitlines(), + new.splitlines(), + fromfile="before", + tofile="after", + lineterm="", + ) + ) + additions = sum( + line.startswith("+") and not line.startswith("+++") + for line in lines + ) + deletions = sum( + line.startswith("-") and not line.startswith("---") + for line in lines + ) + return additions, deletions, "\n".join(lines) + + def refresh_content(self) -> None: + if not self.is_mounted: + return + diff = self.query_one(".edit-diff", Static) + if not self.expanded: + diff.add_class("hidden") + return + diff.remove_class("hidden") + diff.update( + Syntax( + self.diff, + "diff", + word_wrap=True, + background_color=diff.styles.background.hex, + ) + ) + + def set_outcome(self, state: str, detail: str) -> None: + outcome = self.query_one(".edit-outcome", Static) + icon = "✖" if state == "failed" else "⚠" + style = "red" if state == "failed" else "yellow" + outcome.update(Text(f"{icon} {detail}", style=style)) + outcome.remove_class("hidden") + + def _update_expand_hint(self) -> None: + if self.is_mounted: + self.query_one(".edit-hint", Static).update( + "Click to collapse" if self.expanded else "Click to expand" + ) diff --git a/src/ursa/cli/tui/event_cards/plan.py b/src/ursa/cli/tui/event_cards/plan.py new file mode 100644 index 00000000..f8de171d --- /dev/null +++ b/src/ursa/cli/tui/event_cards/plan.py @@ -0,0 +1,156 @@ +# ruff: noqa: TID251 + +"""Plan generation and review event cards.""" + +from collections.abc import Sequence +from typing import Any + +from textual.app import ComposeResult +from textual.widgets import Markdown, Static + +from ursa.cli.tui.event_cards.base import EventCard +from ursa.cli.tui.helpers import _plan_step_text, _truncate_middle + + +class PlanCard(EventCard): + """A live draft/review timeline for one plan revision.""" + + SPINNER_FRAMES = (".", "..", "...") + + def __init__(self, key: str, revision: int) -> None: + super().__init__(key, "🗺️ Planning") + self.revision = revision + self.steps: list[str] = [] + self.state = "drafting" + self.review_reason = "" + self.expanded = False + self._frame = 0 + self._spinner_timer = None + + def compose(self) -> ComposeResult: + yield Markdown("", classes="event-summary") + yield Static("Click to expand", classes="event-expand-hint") + + def on_mount(self) -> None: + self._spinner_timer = self.set_interval(0.3, self._advance_spinner) + self.refresh_content() + self._update_expand_hint() + + def on_resize(self) -> None: + self.refresh_content() + + def _advance_spinner(self) -> None: + if self.state not in {"drafting", "reviewing"}: + if self._spinner_timer is not None: + self._spinner_timer.pause() + return + self._frame = (self._frame + 1) % len(self.SPINNER_FRAMES) + self.refresh_content() + + def _resume_spinner(self) -> None: + if self._spinner_timer is not None: + self._spinner_timer.resume() + + def set_drafting(self) -> None: + self.state = "drafting" + self._resume_spinner() + self.refresh_content() + + def set_plan(self, steps: Sequence[Any]) -> None: + self.steps = [ + _plan_step_text(index, step) for index, step in enumerate(steps, 1) + ] + self.state = "reviewing" + self._resume_spinner() + self.refresh_content() + + def set_reviewing(self) -> None: + self.state = "reviewing" + self._resume_spinner() + self.refresh_content() + + def finish_review(self, approved: bool, reason: str = "") -> None: + self.state = "complete" if approved else "revision_needed" + self.review_reason = "" if approved else reason.strip() + if self._spinner_timer is not None: + self._spinner_timer.pause() + self.refresh_content() + + def finish_pending_review(self, *, succeeded: bool) -> None: + if self.state not in {"drafting", "reviewing"}: + return + if succeeded: + self.finish_review(True) + return + pending_state = self.state + self.state = "revision_needed" + self.review_reason = ( + "Planning stopped before the draft completed." + if pending_state == "drafting" + else "Planning stopped before review completed." + ) + if self._spinner_timer is not None: + self._spinner_timer.pause() + self.refresh_content() + + def _step_width(self) -> int: + """Use the complete rendered row, minus Markdown's list indentation.""" + markdown_width = self.query_one(Markdown).content_size.width + content_width = markdown_width or max(0, self.content_size.width - 4) + return max(0, content_width - 4) + + def refresh_content(self) -> None: + if not self.is_mounted: + return + if self.expanded or len(self.steps) <= 4: + visible = self.steps + else: + hidden = len(self.steps) - 4 + visible = [ + self.steps[0], + self.steps[1], + f"_… {hidden} middle step{'s' if hidden != 1 else ''} hidden …_", + self.steps[-2], + self.steps[-1], + ] + if not self.expanded: + width = self._step_width() + visible = [_truncate_middle(step, width) for step in visible] + + body = [f"**{self.label}** "] + status_indent = " " + if not self.steps: + if self.state == "drafting": + spinner = self.SPINNER_FRAMES[self._frame] + body.append(f"{status_indent}✍️ Drafting Plan{spinner}") + elif self.state == "revision_needed": + body.append(f"{status_indent}❌ Plan drafting failed") + elif self.state == "complete": + body.append(f"{status_indent}✅ 📋 Plan is complete") + else: + plan_label = ( + "Initial Plan" if self.revision == 1 else "Revised Plan" + ) + body.append(f"{status_indent}📄 **{plan_label}**") + body.append("") + for step in visible: + if "middle step" in step: + body.extend(["", f"{status_indent}{step}", ""]) + else: + body.append(step) + body.append("") + if self.state == "reviewing": + spinner = self.SPINNER_FRAMES[self._frame] + body.append(f"{status_indent}📋 Reviewing{spinner}") + elif self.state == "revision_needed": + body.append(f"{status_indent}❌ Plan needs another revision") + elif self.state == "complete": + body.append(f"{status_indent}✅ 📋 Plan is complete") + if self.expanded and self.review_reason: + body.extend([ + "", + f"{status_indent}**Revision feedback**", + *(f"> {line}" for line in self.review_reason.splitlines()), + ]) + + self.query_one(Markdown).update("\n".join(body)) diff --git a/src/ursa/cli/tui/event_cards/search.py b/src/ursa/cli/tui/event_cards/search.py new file mode 100644 index 00000000..ef232fc1 --- /dev/null +++ b/src/ursa/cli/tui/event_cards/search.py @@ -0,0 +1,34 @@ +"""Search progress event cards.""" + +from collections.abc import Mapping +from typing import Any + +from ursa.cli.tui.event_cards.base import EventCard + + +class SearchEventCard(EventCard): + """Live search status with query and result-size details.""" + + def __init__(self, key: str, tool: str) -> None: + label = { + "run_arxiv_search": "arXiv Search", + "run_osti_search": "OSTI Search", + "run_web_search": "Web Search", + }.get(tool, "Search") + super().__init__(key, f"🔎 {label}") + + def update_event(self, payload: Mapping[str, Any]) -> None: + message = str(payload.get("message") or "Searching") + query = str(payload.get("query") or "").strip() + phase = str(payload.get("phase") or "") + icon = "✖" if phase == "error" else "✓" if phase == "end" else "🔎" + summary = f"{icon} {message}" + (f": {query}" if query else "") + detail = ( + payload.get("error") + or payload.get("reason") + or payload.get("preview") + ) + if isinstance(payload.get("result_chars"), int): + size = f"{payload['result_chars']:,} result characters" + detail = f"{detail}\n{size}" if detail else size + self.add(summary, str(detail) if detail else None) diff --git a/src/ursa/cli/tui/event_cards/tools.py b/src/ursa/cli/tui/event_cards/tools.py new file mode 100644 index 00000000..50104fce --- /dev/null +++ b/src/ursa/cli/tui/event_cards/tools.py @@ -0,0 +1,186 @@ +# ruff: noqa: TID251 + +"""Default card for tool calls without a specialized presentation.""" + +from __future__ import annotations + +import json +from collections.abc import Mapping +from typing import Any + +from langchain_core.messages import ToolMessage +from rich.syntax import Syntax +from rich.text import Text +from textual.app import ComposeResult +from textual.containers import Horizontal, Vertical +from textual.widgets import Markdown, Static + +from ursa.cli.tui.event_cards.base import EventCard +from ursa.cli.tui.widgets import ActivityIndicator + + +def _json(value: Any, *, compact: bool) -> str: + return json.dumps( + value, + ensure_ascii=False, + default=str, + indent=None if compact else 2, + separators=(",", ":") if compact else None, + sort_keys=True, + ) + + +class ToolCallCard(EventCard): + """Live JSON input/output inspector for a single tool invocation.""" + + def __init__(self, key: str, tool: str, tool_input: Any) -> None: + super().__init__(key, tool) + self.tool = tool + self.tool_input = tool_input + self.output: Any = None + self.structured_output: Any = None + self.has_structured_output = False + self.completed = False + self.failed = False + self._frame = 0 + self._spinner_timer = None + + def compose(self) -> ComposeResult: + yield Static(Text(f"🛠️ {self.tool}"), classes="tool-call-title") + with Horizontal(classes="tool-call-summary"): + yield Static(ActivityIndicator.FRAMES[0], classes="tool-call-state") + yield Static( + self._preview(self.tool_input), classes="tool-call-preview" + ) + with Horizontal(classes="tool-call-details hidden"): + with Vertical(classes="tool-json-pane tool-input-pane"): + yield Static("Input", classes="tool-json-title") + yield Static(classes="tool-json tool-input-json") + with Vertical(classes="tool-json-pane tool-output-pane hidden"): + yield Static("Output", classes="tool-json-title") + yield Static(classes="tool-json tool-output-json") + yield Markdown(classes="tool-output-markdown hidden") + yield Static("Click to expand", classes="event-expand-hint") + + def on_mount(self) -> None: + self._spinner_timer = self.set_interval(0.08, self._advance_spinner) + self._render_details() + self._update_expand_hint() + + @staticmethod + def _preview(value: Any, limit: int = 120) -> Text: + preview = _json(value, compact=True) + if len(preview) > limit: + preview = preview[: limit - 1] + "…" + return Text(preview) + + @staticmethod + def _syntax(value: Any) -> Syntax: + return Syntax( + _json(value, compact=False), + "json", + word_wrap=True, + background_color="default", + ) + + def _advance_spinner(self) -> None: + self.query_one(".tool-call-state", Static).update( + ActivityIndicator.FRAMES[self._frame] + ) + self._frame = (self._frame + 1) % len(ActivityIndicator.FRAMES) + + def complete(self, output: Any, *, failed: bool = False) -> None: + if isinstance(output, ToolMessage): + failed = failed or output.status == "error" + self.output = self._text_content(output.content) + artifact = output.artifact + if ( + isinstance(artifact, Mapping) + and "structured_content" in artifact + ): + self.structured_output = artifact["structured_content"] + self.has_structured_output = True + else: + self.output = output + self.completed = True + self.failed = failed + self.done = True + if self._spinner_timer is not None: + self._spinner_timer.pause() + self.query_one(".tool-call-state", Static).update( + "✗" if failed else "✓" + ) + self.query_one(".tool-call-preview", Static).update( + self._preview( + self.structured_output + if self.has_structured_output + else self.output + ) + ) + self._render_details() + + def update_event(self, payload: Mapping[str, Any]) -> None: + phase = str(payload.get("phase") or "") + if phase not in {"end", "error"}: + return + failed = phase == "error" or payload.get("status") == "error" + output = ( + payload.get("error") + if failed + else payload.get("tool_message", payload.get("result")) + ) + self.complete(output, failed=failed) + + @staticmethod + def _text_content(content: Any) -> Any: + if not isinstance(content, list): + return content + parts = [ + item + if isinstance(item, str) + else item.get("text") + if isinstance(item, Mapping) and item.get("type") == "text" + else None + for item in content + ] + return ( + "\n".join(parts) + if all(isinstance(part, str) for part in parts) + else content + ) + + def set_expanded(self, expanded: bool) -> None: + self.expanded = expanded + if self.is_mounted: + self._render_details() + self.query_one(".tool-call-details").set_class( + not expanded, "hidden" + ) + self._update_expand_hint() + + def _render_details(self) -> None: + if not self.is_mounted: + return + self.query_one(".tool-input-json", Static).update( + self._syntax(self.tool_input) + ) + output_pane = self.query_one(".tool-output-pane") + output_pane.set_class(not self.completed, "hidden") + if self.completed: + json_output = self.query_one(".tool-output-json", Static) + markdown_output = self.query_one(".tool-output-markdown", Markdown) + render_as_json = self.has_structured_output or not isinstance( + self.output, str + ) + json_output.set_class(not render_as_json, "hidden") + markdown_output.set_class(render_as_json, "hidden") + if render_as_json: + json_output.update( + self._syntax( + self.structured_output + if self.has_structured_output + else self.output + ) + ) + else: + markdown_output.update(self.output) diff --git a/src/ursa/cli/tui/event_handler.py b/src/ursa/cli/tui/event_handler.py new file mode 100644 index 00000000..b0569041 --- /dev/null +++ b/src/ursa/cli/tui/event_handler.py @@ -0,0 +1,169 @@ +"""LangChain callback translation for the Textual conversation view.""" + +from __future__ import annotations + +import json +from time import monotonic +from typing import TYPE_CHECKING, Any + +from langchain_core.callbacks import AsyncCallbackHandler +from langchain_core.messages import ToolMessage + +from ursa.cli.tui.helpers import ( + FILE_TOOLS, + TokenUsage, + _reasoning_trace, + _token_usage_breakdown, +) +from ursa.cli.tui.turn import Turn +from ursa.util.events import DEFAULT_EVENT_NAME + +if TYPE_CHECKING: + from ursa.cli.tui.app import UrsaTextualApp + + +class TextualEventHandler(AsyncCallbackHandler): + """Translate LangChain callbacks into live Textual turn updates.""" + + def __init__(self, app: UrsaTextualApp, turn: Turn) -> None: + self.app = app + self.turn = turn + self.tools: dict[Any, dict[str, Any]] = {} + + async def _emit(self, data: dict[str, Any]) -> None: + """Apply callback data on Textual's event-loop thread.""" + data.setdefault("_received_at", monotonic()) + if self.app.is_ui_thread: + await self.app.add_turn_event(self.turn, data) + else: + self.app.call_from_thread( + self.app.add_turn_event, + self.turn, + data, + ) + + async def _update_activity(self, message: str) -> None: + if self.app.is_ui_thread: + self.turn.update_activity(message) + else: + self.app.call_from_thread(self.turn.update_activity, message) + + async def on_custom_event( + self, + name: str, + data: Any, + *, + run_id: Any = None, + **_: Any, + ) -> None: + if name == DEFAULT_EVENT_NAME and isinstance(data, dict): + data = dict(data) + if data.get("tool") == "run_command" and run_id is not None: + # LangChain assigns custom events emitted inside a tool to the + # tool run. The query text is not a unique identifier when + # identical commands execute concurrently. + data["_command_id"] = str(run_id) + tool = str(data.get("tool") or "") + # The file tools publish their own structured range events while + # LangChain also emits tool start/end callbacks. The callback is + # the authoritative timeline event; rendering both produces a + # second group when the range completes after later activity. + if tool in FILE_TOOLS: + phase = str(data.get("phase") or "") + if phase in {"end", "error"}: + result = str(data.get("result") or data.get("error") or "") + if phase == "error" or result.casefold().startswith(( + "failed", + "no changes made", + )): + await self._emit(data) + return + if any( + pending.get("tool") == tool + for pending in self.tools.values() + ): + return + await self._emit(data) + + async def on_llm_start(self, *_: Any, **__: Any) -> None: + await self._update_activity("Thinking…") + + async def on_chat_model_start(self, *_: Any, **__: Any) -> None: + await self._update_activity("Thinking…") + + async def on_llm_new_token( + self, token: str, *, chunk: Any = None, **_: Any + ) -> None: + # Ordinary answer tokens are intentionally ignored. Providers that + # publish reasoning summaries place them in explicit reasoning or + # thinking fields on the chunk. + if trace := _reasoning_trace(chunk): + await self._update_activity(trace) + + async def on_tool_start( + self, + serialized: dict[str, Any], + input_str: str, + *, + run_id: Any, + inputs: dict[str, Any] | None = None, + **_: Any, + ) -> None: + data = dict(inputs or {}) + if not data and input_str: + try: + parsed = json.loads(input_str) + if isinstance(parsed, dict): + data = parsed + except json.JSONDecodeError: + data = {"input": input_str} + data["tool"] = serialized.get("name", "tool") + data["phase"] = "start" + data["_run_id"] = str(run_id) + if data["tool"] == "run_command": + data["_command_id"] = data["_run_id"] + self.tools[run_id] = data + await self._emit(data) + + async def on_tool_end(self, output: Any, *, run_id: Any, **_: Any) -> None: + data = self.tools.pop(run_id, {"tool": "tool"}) + data = {**data, "phase": "end"} + if isinstance(output, ToolMessage): + data["result"] = output.content + data["status"] = output.status + data["tool_message"] = output + else: + data["result"] = output + if data.get("tool") in FILE_TOOLS and Turn._file_outcome(data) is None: + return + await self._emit(data) + + async def on_tool_error( + self, error: BaseException, *, run_id: Any, **_: Any + ) -> None: + data = self.tools.pop(run_id, {"tool": "tool"}) + if data.get("tool") in FILE_TOOLS: + await self._emit({ + **data, + "phase": "error", + "error": str(error), + "result": str(error), + }) + return + await self._emit({ + **data, + "phase": "error", + "error": str(error), + "result": str(error), + }) + + async def on_llm_end(self, response: Any, **_: Any) -> None: + usage = _token_usage_breakdown(response) + if self.app.is_ui_thread: + self._record_tokens(usage) + else: + self.app.call_from_thread(self._record_tokens, usage) + + def _record_tokens(self, usage: TokenUsage) -> None: + self.turn.add_tokens(usage.total_tokens) + self.app.add_tokens(usage) diff --git a/src/ursa/cli/tui/helpers.py b/src/ursa/cli/tui/helpers.py new file mode 100644 index 00000000..b355e6b9 --- /dev/null +++ b/src/ursa/cli/tui/helpers.py @@ -0,0 +1,315 @@ +"""Pure helpers and constants shared by the Textual CLI.""" + +import re +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any + +from rich.cells import cell_len, chop_cells # noqa: TID251 + +from ursa.cli.runtime import HITL + +SUMMARY_GROUP_GRACE_SECONDS = 3.0 +FILE_TOOLS = { + "read_file": "Reading", + "write_code": "Editing", + "write_code_with_repo": "Editing", + "edit_code": "Editing", +} + +SEARCH_TOOLS = { + "run_arxiv_search", + "run_osti_search", + "run_web_search", +} + +AGENT_LABELS = { + "ExecutionAgent": ("⚙️", "Execute"), + "DeepReviewAgent": ("🔎", "Deep Review"), + "HypothesizerAgent": ("💡", "Hypothesize"), + "LammpsAgent": ("⚛️", "LAMMPS"), + "PlanningAgent": ("🗺️", "Plan"), + "executor": ("⚙️", "Execute"), + "deep_review": ("🔎", "Deep Review"), + "hypothesizer": ("💡", "Hypothesize"), + "lammps": ("⚛️", "LAMMPS"), + "planner": ("🗺️", "Plan"), +} + +COMMAND_CHOICES = { + "agents": "Configured agents, descriptions, options, and tools", + "exit": "Quit URSA gracefully", + "status": "Tokens, models, endpoints, group, and MCP servers", + "keymap": "Complete keyboard map", + "models": "Switch chat or embedding inference providers", + "theme": "Choose the application color theme", +} + + +def _plan_step_text(index: int, step: Any) -> str: + """Normalize one plan step into numbered display text.""" + if not isinstance(step, Mapping): + dump = getattr(step, "model_dump", None) + step = dump() if callable(dump) else {"name": str(step)} + name = str(step.get("name") or f"Step {index}") + description = " ".join(str(step.get("description") or "").split()) + return f"{index}. {name}" + (f": {description}" if description else "") + + +def _truncate_middle(text: str, width: int) -> str: + """Fit text to a terminal-cell width while preserving both ends.""" + if cell_len(text) <= width: + return text + marker = " … truncated … " + available = width - cell_len(marker) + if available < 0: + if width <= 0: + return "" + if width == 1: + return "…" + return f"{chop_cells(text, width - 1)[0]}…" + if available == 0: + return f"_{marker.strip()}_" + left = (available + 1) // 2 + right = available // 2 + prefix = chop_cells(text, left)[0] + suffix = chop_cells(text[::-1], right)[0][::-1] + return f"{prefix} _… truncated …_ {suffix}" + + +def _inference_provider(value: str | None) -> str: + """Return a concise inference-provider label.""" + return value or "default" + + +def _embedding_name(hitl: HITL) -> str: + embedding = getattr(hitl, "embedding", None) + if embedding is None: + return "none" + for attribute in ("model_name", "model"): + if value := getattr(embedding, attribute, None): + return str(value) + return type(embedding).__name__ + + +def _route_prompt(hitl: HITL, prompt: str) -> tuple[str, str]: + """Route a leading ``#agent`` macro, defaulting to chat.""" + match = re.match( + r"^#(?P\S+)(?:\s(?P.*))?$", prompt, re.DOTALL + ) + if match and match["name"] in hitl.agents: + return match["name"], match["prompt"] or "" + return "chat", prompt + + +def _fuzzy_match(query: str, candidate: str) -> bool: + """Return whether all query characters occur in order in candidate.""" + return _fuzzy_score(query, candidate) is not None + + +def _field_fuzzy_score(query: str, value: str) -> int | None: + """Score a fuzzy subsequence, favoring compact and early matches.""" + query = query.casefold() + value = value.casefold() + if not query: + return 0 + positions: list[int] = [] + start = 0 + for character in query: + position = value.find(character, start) + if position < 0: + return None + positions.append(position) + start = position + 1 + span = positions[-1] - positions[0] + 1 + score = 1000 - positions[0] * 4 - (span - len(query)) * 8 + if query == value: + score += 3000 + elif value.startswith(query): + score += 2000 + elif query in value: + score += 1000 + return score + + +def _fuzzy_score(query: str, candidate: str) -> int | None: + """Rank matches, strongly preferring a picker's primary name field.""" + primary, separator, description = candidate.partition(" — ") + primary_score = _field_fuzzy_score(query, primary) + description_score = ( + _field_fuzzy_score(query, description) if separator else None + ) + scores = [] + if primary_score is not None: + scores.append(10_000 + primary_score) + if description_score is not None: + scores.append(description_score) + return max(scores) if scores else None + + +@dataclass(frozen=True) +class TokenUsage: + """Normalized token counts from one model response.""" + + input_tokens: int = 0 + output_tokens: int = 0 + cached_tokens: int = 0 + total_tokens: int = 0 + + +def _token_usage_breakdown(value: Any) -> TokenUsage: + """Extract token counts from common LangChain/provider response shapes.""" + seen: set[int] = set() + candidates: list[TokenUsage] = [] + + def count(item: Any) -> int: + return ( + item if isinstance(item, int) and not isinstance(item, bool) else 0 + ) + + def mapping_usage(item: Mapping[str, Any]) -> TokenUsage: + input_tokens = max( + count(item.get(key)) + for key in ("input_tokens", "prompt_tokens", "input_token_count") + ) + output_tokens = max( + count(item.get(key)) + for key in ( + "output_tokens", + "completion_tokens", + "output_token_count", + ) + ) + cached_tokens = max( + count(item.get(key)) + for key in ( + "cached_tokens", + "cached_input_tokens", + "cache_read_input_tokens", + "prompt_cache_hits", + ) + ) + for details_key in ( + "input_token_details", + "input_tokens_details", + "prompt_tokens_details", + ): + details = item.get(details_key) + if isinstance(details, Mapping): + cached_tokens = max( + cached_tokens, + count(details.get("cached_tokens")), + count(details.get("cache_read")), + ) + total_tokens = max( + count(item.get("total_tokens")), + count(item.get("total_token_count")), + input_tokens + output_tokens, + ) + return TokenUsage( + input_tokens, + output_tokens, + cached_tokens, + total_tokens, + ) + + def visit(item: Any) -> None: + if item is None or id(item) in seen: + return + seen.add(id(item)) + if isinstance(item, Mapping): + usage = mapping_usage(item) + if usage != TokenUsage(): + candidates.append(usage) + for child in item.values(): + visit(child) + return + if isinstance(item, (list, tuple)): + for child in item: + visit(child) + return + for attribute in ( + "llm_output", + "usage_metadata", + "response_metadata", + "generations", + "message", + ): + if hasattr(item, attribute): + visit(getattr(item, attribute)) + + visit(value) + if not candidates: + return TokenUsage() + best = max( + candidates, + key=lambda usage: ( + bool(usage.input_tokens) + bool(usage.output_tokens), + usage.total_tokens, + ), + ) + return TokenUsage( + best.input_tokens, + best.output_tokens, + max(usage.cached_tokens for usage in candidates), + best.total_tokens, + ) + + +def _token_usage(value: Any) -> int: + """Extract total token usage from common LangChain response shapes.""" + return _token_usage_breakdown(value).total_tokens + + +def _reasoning_trace(chunk: Any) -> str | None: + """Extract provider-published reasoning summaries from an LLM chunk.""" + + def text(value: Any) -> str: + if isinstance(value, str): + return value + if isinstance(value, Mapping): + return " ".join( + text(value.get(key)) + for key in ( + "text", + "content", + "summary", + "reasoning", + "thinking", + ) + if value.get(key) + ) + if isinstance(value, (list, tuple)): + return " ".join(filter(None, (text(item) for item in value))) + return "" + + values = [chunk, getattr(chunk, "message", None)] + for value in values: + mappings = [value] if isinstance(value, Mapping) else [] + for attribute in ("additional_kwargs", "response_metadata"): + mapping = getattr(value, attribute, None) + if isinstance(mapping, Mapping): + mappings.append(mapping) + for mapping in mappings: + for key in ( + "reasoning_content", + "reasoning_summary", + "reasoning", + "thinking", + ): + if trace := " ".join(text(mapping.get(key)).split()): + return trace[-500:] + + content = getattr(value, "content", None) + if isinstance(content, list): + for block in content: + if not isinstance(block, Mapping): + continue + if str(block.get("type", "")).casefold() in { + "reasoning", + "reasoning_summary", + "thinking", + }: + if trace := " ".join(text(block).split()): + return trace[-500:] + return None diff --git a/src/ursa/cli/tui/themes.py b/src/ursa/cli/tui/themes.py new file mode 100644 index 00000000..7f14fc76 --- /dev/null +++ b/src/ursa/cli/tui/themes.py @@ -0,0 +1,36 @@ +"""URSA themes for the Textual CLI.""" + +from textual.theme import BUILTIN_THEMES, Theme + +URSA_DARK = Theme( + name="ursa-dark", + primary="#0178D4", + secondary="#004578", + accent="#ffa62b", + warning="#ffa62b", + error="#ba3c5b", + success="#4EBF71", + foreground="#d5dbe0", + background="#101317", + surface="#14191d", + panel="#202830", +) + + +URSA_LIGHT = Theme( + name="ursa-light", + primary="#496f91", + secondary="#5d8068", + accent="#496f91", + warning="#9b6b22", + error="#a83f50", + success="#39734a", + foreground="#24313c", + background="#f5f7f9", + surface="#ffffff", + panel="#e7edf2", + dark=False, +) + + +AVAILABLE_THEMES = (URSA_DARK, URSA_LIGHT, *BUILTIN_THEMES.values()) diff --git a/src/ursa/cli/tui/tips.py b/src/ursa/cli/tui/tips.py new file mode 100644 index 00000000..2888a138 --- /dev/null +++ b/src/ursa/cli/tui/tips.py @@ -0,0 +1,76 @@ +"""Short hints displayed in the Textual welcome banner.""" + +from __future__ import annotations + +import random +from collections.abc import Iterable +from typing import TYPE_CHECKING + +from textual.binding import Binding + +if TYPE_CHECKING: + from textual.app import App + +TIPS = ( + "{agent_macro} opens the fuzzy agent picker and routes the prompt.", + "{file_macro} finds workspace files and directories without leaving the app.", + "{command_macro} opens commands for managing agents, viewing status, and displaying the complete keymap.", + "{cancel} closes a picker without changing your prompt.", + "{insert_newline} adds a newline; {submit_prompt} submits the prompt.", + "{clear_prompt} clears the prompt; {history_up} restores it from history.", + "Tool and agent outputs are truncated by default; {toggle_card_details} toggles the full output.", + "{previous_turn_marker} and {next_turn_marker} jump to the previous or next turn marker.", + "{agent_macro}agent routes your next prompt to that agent without changing the default.", + "Named agents preserve state between sessions. Start URSA with `--name` to resume one.", + "MCP tools are attached only to agents that support tools.", + "{quit} waits for the active turn before quitting; {hard_quit} quits immediately.", + "Use {command_macro}agents to explore available agents and their tools.", + "Use {command_macro}keymap to see all available keyboard shortcuts.", + "Use {command_macro}theme to change the color theme.", + "Switch URSA's chat and embedding models with {command_macro}models", + "Found a problem? Let us know: https://github.com/lanl/ursa/issues", + "Unsure about something? Check out our docs: https://lanl.github.io/ursa", +) + +BEAR_FACTS = ( + "Despite their name, black bears can be black, cinnamon, brown, blond, and even white.", # https://www.nps.gov/subjects/bears/black-bears.htm + "Polar bears can smell a carcass from nearly 20 miles away.", # https://www.nps.gov/subjects/bears/polar-bears.htm + "A Kodiak brown bear can be up to 10 feet tall when standing upright.", # https://www.fws.gov/species/kodiak-brown-bear-ursus-arctos-middendorffi + "A black bear can run as fast as 35 miles per hour.", # https://www.nps.gov/glac/learn/nature/bears.htm + "The Andean bear, also known as the spectacled bear, is the only bear native to South America.", # https://nationalzoo.si.edu/animals/andean-bear +) + + +def _effective_bindings(owner: type[object]) -> Iterable[Binding]: + """Yield an owner's bindings with runtime subclass overrides applied.""" + bindings: dict[str, Binding] = {} + for base in reversed(owner.__mro__): + for binding in Binding.make_bindings(base.__dict__.get("BINDINGS", ())): + bindings[binding.key] = binding + return bindings.values() + + +def runtime_keymap( + app: App[object], owners: Iterable[type[object]] +) -> dict[str, str]: + """Map binding actions to their current, terminal-friendly key labels.""" + keymap: dict[str, list[str]] = {} + newline_key = getattr(app, "preferred_newline_key", "ctrl+j") + for owner in owners: + for binding in _effective_bindings(owner): + if ( + binding.action == "insert_newline" + and binding.key != newline_key + ): + continue + keymap.setdefault(binding.action, []).append( + app.get_key_display(binding) + ) + return {action: " / ".join(keys) for action, keys in keymap.items()} + + +def random_tip(app: App[object], owners: Iterable[type[object]]) -> str: + """Choose one welcome hint for the current application session.""" + if random.random() <= (1 / (len(TIPS) + 1)): + return random.choice(BEAR_FACTS) + return random.choice(TIPS).format_map(runtime_keymap(app, owners)) diff --git a/src/ursa/cli/tui/turn.py b/src/ursa/cli/tui/turn.py new file mode 100644 index 00000000..62fbc4aa --- /dev/null +++ b/src/ursa/cli/tui/turn.py @@ -0,0 +1,491 @@ +"""Conversation-turn state and event-to-card orchestration.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Mapping +from pathlib import Path +from time import monotonic +from typing import Any + +from textual.app import ComposeResult +from textual.containers import Vertical +from textual.widgets import Static + +from ursa.cli.tui.event_cards import ( + AgentEventCard, + ArtifactCard, + EditCard, + EventCard, + ExceptionCard, + FileActivityCard, + PlanCard, + RunCommandCard, + SearchEventCard, + ToolCallCard, +) +from ursa.cli.tui.helpers import ( + AGENT_LABELS, + FILE_TOOLS, + SEARCH_TOOLS, + SUMMARY_GROUP_GRACE_SECONDS, +) +from ursa.cli.tui.widgets import ActivityIndicator, MessageCard +from ursa.util.rendering import event_artifacts + + +class Turn(Static): + def __init__(self, prompt: str, workspace: Path) -> None: + super().__init__(classes="turn") + self.prompt = prompt + self.workspace = Path(workspace).resolve() + self.cards: dict[str, EventCard] = {} + self.started_at = monotonic() + self.token_usage = 0 + self._command_count = 0 + self._commands: list[RunCommandCard] = [] + self._commands_by_id: dict[str, RunCommandCard] = {} + self._commands_overlapped = False + self._tool_calls_by_id: dict[str, ToolCallCard] = {} + self._edits_by_id: dict[str, EditCard] = {} + self._summary_count = 0 + self._summary_cards: dict[str, EventCard] = {} + self._summary_deadlines: dict[str, float] = {} + self._summary_timers: dict[str, Any] = {} + self._plan_cards: list[PlanCard] = [] + self._current_event_at = self.started_at + # LangChain may invoke callbacks for parallel tools concurrently. Keep + # the read/decide/mount/update sequence atomic so those callbacks all + # observe the same current summary group. + self._event_lock = asyncio.Lock() + self.card_details_expanded = False + + def compose(self) -> ComposeResult: + yield MessageCard("user", self.prompt) + yield Vertical(classes="events hidden") + activity = ActivityIndicator() + activity.add_class("activity-after-user") + yield activity + yield Static("", classes="turn-end-marker") + + async def event(self, payload: dict[str, Any]) -> None: + async with self._event_lock: + await self._event(payload) + + async def _event(self, payload: dict[str, Any]) -> None: + received_at = payload.get("_received_at") + self._current_event_at = ( + float(received_at) + if isinstance(received_at, int | float) + else monotonic() + ) + activity = ( + payload.get("message") + or payload.get("reasoning_content") + or payload.get("reasoning_summary") + or payload.get("reasoning") + or payload.get("thinking") + or payload.get("stage") + ) + if activity: + self.update_activity(str(activity)) + tool = str(payload.get("tool") or "") + agent = str(payload.get("agent") or "") + stage = str(payload.get("stage") or "") + path = str(payload.get("path") or payload.get("filename") or "").strip() + if path: + candidate = Path(path).expanduser() + if not candidate.is_absolute(): + candidate = self.workspace / candidate + candidate = candidate.resolve() + try: + path = str(candidate.relative_to(self.workspace)) + except ValueError: + path = str(candidate) + old = payload.get("old_code") + new = payload.get("new_code") + if tool == "run_command": + await self._run_command_event(payload) + return + artifacts = event_artifacts(payload) + if artifacts: + artifact_key = self._next_summary_key("artifact") + await self._replace_or_mount( + artifact_key, ArtifactCard(artifact_key, artifacts) + ) + if agent in {"PlanningAgent", "planner"} and stage in { + "generate", + "generate_result", + "reflect", + "reflect_result", + }: + await self._plan_event(payload) + return + edit_card = None + additions = payload.get("additions") + deletions = payload.get("deletions") + outcome = self._file_outcome(payload) + edit_id = str(payload.get("_run_id") or "") + if tool in {"edit_code", "write_code", "write_code_with_repo"} and ( + outcome is not None + ): + existing_edit = self._edits_by_id.get(edit_id) + if existing_edit is None: + matching_edits = [ + candidate + for candidate in self.cards.values() + if isinstance(candidate, EditCard) + and candidate.path == path + ] + if len(matching_edits) == 1: + existing_edit = matching_edits[0] + if existing_edit is not None: + existing_edit.set_outcome(*outcome) + return + code = payload.get("code") + change: tuple[str, str] | None = None + if path: + if tool in {"write_code", "write_code_with_repo"} and isinstance( + code, str + ): + change = ("", code) + elif tool == "edit_code" and (old is not None or new is not None): + change = (str(old or ""), str(new or "")) + if change is not None: + has_edit_heading = any( + isinstance(candidate, EditCard) + or ( + isinstance(candidate, FileActivityCard) + and bool(candidate.files["Editing"]) + ) + for candidate in self.cards.values() + ) + edit_card = EditCard( + path, + *change, + show_heading=not has_edit_heading, + ) + if edit_card is not None: + edit_key = self._next_summary_key("edit") + await self._replace_or_mount(edit_key, edit_card) + if edit_id: + self._edits_by_id[edit_id] = edit_card + return + + if tool in {"edit_code", "write_code", "write_code_with_repo"} and any( + isinstance(candidate, EditCard) and candidate.path == path + for candidate in self.cards.values() + ): + # A follow-up callback without diff content belongs to the rich + # edit row already mounted for this file. + return + + if tool in FILE_TOOLS and path: + operation = FILE_TOOLS[tool] + if outcome is not None: + card = self._latest_file_card(operation, path) + if card is None: + key = self._next_summary_key("files") + card = FileActivityCard(key) + card.add_file(operation, path) + await self._replace_or_mount(key, card) + state, detail = outcome + card.record_outcome(operation, path, state, detail) + return + summary_kind = f"files:{operation}" + self._prepare_summary(summary_kind) + card = ( + self._summary_cards.get(summary_kind) + if self._can_reuse_summary(summary_kind) + and isinstance( + self._summary_cards.get(summary_kind), + FileActivityCard, + ) + else None + ) + if card is None: + key = self._next_summary_key("files") + card = FileActivityCard(key) + await self._replace_or_mount(key, card) + self._mark_summary(summary_kind, card) + card.add_file( + operation, + path, + additions=additions if isinstance(additions, int) else None, + deletions=deletions if isinstance(deletions, int) else None, + ) + return + + if tool and tool not in SEARCH_TOOLS and agent not in AGENT_LABELS: + await self._default_tool_event(payload) + return + + label = str(payload.get("message") or stage or tool or "Event") + source = str(agent or tool or "agent") + summary_kind = f"progress:{source}" + card_type: type[EventCard] = ( + AgentEventCard + if agent in AGENT_LABELS + else SearchEventCard + if tool in SEARCH_TOOLS + else EventCard + ) + self._prepare_summary(summary_kind) + card = ( + self._summary_cards.get(summary_kind) + if self._can_reuse_summary(summary_kind) + and type(self._summary_cards.get(summary_kind)) is card_type + else None + ) + if card is None: + key = self._next_summary_key("progress") + card = ( + AgentEventCard(key, agent) + if agent in AGENT_LABELS + else SearchEventCard(key, tool) + if tool in SEARCH_TOOLS + else EventCard(key, f"◌ {source}") + ) + await self._replace_or_mount(key, card) + self._mark_summary(summary_kind, card) + if isinstance(card, AgentEventCard): + card.update_event(payload) + elif isinstance(card, SearchEventCard): + card.update_event(payload) + else: + detail = payload.get("error") or payload.get("preview") + card.add(label, str(detail) if detail else None) + + async def _plan_event(self, payload: Mapping[str, Any]) -> None: + """Update the live card for one planning/review revision.""" + stage = str(payload.get("stage") or "") + if stage == "generate" or not self._plan_cards: + for previous in self._plan_cards: + previous.set_expanded(False) + plan_key = self._next_summary_key("plan") + plan = PlanCard(plan_key, len(self._plan_cards) + 1) + self._plan_cards.append(plan) + await self._replace_or_mount(plan_key, plan) + else: + plan = self._plan_cards[-1] + + if stage == "generate": + plan.set_drafting() + elif stage == "generate_result" and isinstance( + payload.get("steps"), list + ): + plan.set_plan(payload["steps"]) + elif stage == "reflect": + plan.set_reviewing() + elif stage == "reflect_result": + plan.finish_review( + bool(payload.get("approved")), + str(payload.get("reason") or ""), + ) + + async def _default_tool_event(self, payload: Mapping[str, Any]) -> None: + run_id = str(payload.get("_run_id") or "") + card = self._tool_calls_by_id.get(run_id) if run_id else None + phase = str(payload.get("phase") or "") + if card is None: + ignored = { + "tool", + "phase", + "result", + "error", + "status", + "tool_message", + "_run_id", + "_received_at", + } + tool_input = { + key: value + for key, value in payload.items() + if key not in ignored + } + key = self._next_summary_key("tool") + card = ToolCallCard(key, str(payload.get("tool")), tool_input) + await self._replace_or_mount(key, card) + if run_id: + self._tool_calls_by_id[run_id] = card + if phase in {"end", "error"}: + card.update_event(payload) + + def _latest_file_card( + self, operation: str, path: str + ) -> FileActivityCard | None: + for card in reversed(list(self.cards.values())): + if ( + isinstance(card, FileActivityCard) + and path in card.files[operation] + ): + return card + return None + + @staticmethod + def _file_outcome(payload: Mapping[str, Any]) -> tuple[str, str] | None: + phase = str(payload.get("phase") or "") + result = str(payload.get("result") or payload.get("error") or "") + failed = phase == "error" or payload.get("status") == "error" + failed = failed or result.casefold().startswith("failed") + if failed: + return "failed", result + if result.casefold().startswith("no changes made"): + return "unchanged", result + return None + + def _next_summary_key(self, prefix: str) -> str: + self._summary_count += 1 + return f"{prefix}:{self._summary_count}" + + def _can_reuse_summary(self, kind: str) -> bool: + card = self._summary_cards.get(kind) + deadline = self._summary_deadlines.get(kind) + return ( + card is not None + and not card.done + and deadline is not None + and self._current_event_at < deadline + ) + + def _prepare_summary(self, kind: str) -> None: + deadline = self._summary_deadlines.get(kind) + if deadline is not None and self._current_event_at >= deadline: + self._finalize_summary(kind) + + def _mark_summary(self, kind: str, card: EventCard) -> None: + self._summary_cards[kind] = card + deadline = self._current_event_at + SUMMARY_GROUP_GRACE_SECONDS + self._summary_deadlines[kind] = deadline + timer = self._summary_timers.pop(kind, None) + if timer is not None: + timer.stop() + delay = max(0, deadline - monotonic()) + self._summary_timers[kind] = self.set_timer( + delay, + lambda: self._finalize_summary(kind, card), + ) + + def _finalize_summary( + self, + kind: str, + expected_card: EventCard | None = None, + ) -> None: + card = self._summary_cards.get(kind) + if expected_card is not None and card is not expected_card: + return + timer = self._summary_timers.pop(kind, None) + if timer is not None: + timer.stop() + if card is not None: + card.mark_done() + self._summary_cards.pop(kind, None) + self._summary_deadlines.pop(kind, None) + + def _finalize_summaries(self) -> None: + for kind in list(self._summary_cards): + self._finalize_summary(kind) + + async def _run_command_event(self, payload: dict[str, Any]) -> None: + command = str(payload.get("query") or "").strip() + command_id = str(payload.get("_command_id") or "") + key = f"command:{command_id}" if command_id else "" + card = self._commands_by_id.get(command_id) if command_id else None + if card is None and str(payload.get("phase") or "") != "start": + matching = [ + candidate + for candidate in self._commands + if candidate.command == command and not candidate.completed + ] + if len(matching) == 1: + # Some callback providers assign custom tool events a child + # run ID instead of the ID used by on_tool_start. A unique + # active command match is safe to correlate and avoids a + # duplicate card stuck in its initial state. + card = matching[0] + if command_id: + self._commands_by_id[command_id] = card + if card is None: + if self._commands_overlapped and not any( + command.completed is False for command in self._commands + ): + # A new solitary command starts a fresh layout batch. Preserve + # compact history from an earlier overlapping batch without + # forcing all later commands to remain compact forever. + for previous in self._commands: + previous.force_compact = True + self._commands_overlapped = False + self._command_count += 1 + key = key or f"command:{self._command_count}" + card = RunCommandCard(key, command or "(command unavailable)") + await self._replace_or_mount(key, card) + self._commands.append(card) + if command_id: + self._commands_by_id[command_id] = card + assert isinstance(card, RunCommandCard) + card.update_event(payload) + self._update_command_layout() + + def _update_command_layout(self) -> None: + active = [ + command for command in self._commands if not command.completed + ] + if len(active) > 1: + self._commands_overlapped = True + detailed: set[RunCommandCard] = set() + if self._commands_overlapped: + detailed = set() + elif len(active) == 1: + detailed.add(active[0]) + + for command_card in self._commands: + command_card.set_multi_command( + command_card not in detailed or command_card.force_compact + ) + command_card.set_output_expanded(self.card_details_expanded) + + def update_activity(self, message: str) -> None: + self.query_one(ActivityIndicator).update_message(message) + + def add_tokens(self, count: int) -> None: + self.token_usage += count + + def finish_activity(self, *, succeeded: bool = True) -> None: + for plan in self._plan_cards: + plan.finish_pending_review(succeeded=succeeded) + self._finalize_summaries() + self.query_one(ActivityIndicator).finish( + elapsed=monotonic() - self.started_at, + tokens=self.token_usage, + ) + + async def _replace_or_mount(self, key: str, card: EventCard) -> None: + previous = self.cards.get(key) + if previous is not None: + await previous.remove() + self.cards[key] = card + events = self.query_one(".events", Vertical) + events.add_class("has-events") + events.remove_class("hidden") + self.query_one(ActivityIndicator).remove_class("activity-after-user") + await events.mount(card) + if self.card_details_expanded: + card.set_expanded(True) + + async def add_response(self, response: str) -> None: + message = MessageCard("assistant", response) + await self.mount(message, before=self.query_one(".turn-end-marker")) + + async def add_exception( + self, error: BaseException, traceback: str + ) -> ExceptionCard: + """Add an expandable failure card without discarding traceback data.""" + key = self._next_summary_key("exception") + card = ExceptionCard(key, error, traceback) + await self._replace_or_mount(key, card) + return card + + def set_card_details_expanded(self, expanded: bool) -> None: + self.card_details_expanded = expanded + for card in self.cards.values(): + card.set_expanded(expanded) diff --git a/src/ursa/cli/tui/widgets.py b/src/ursa/cli/tui/widgets.py new file mode 100644 index 00000000..1da7fea2 --- /dev/null +++ b/src/ursa/cli/tui/widgets.py @@ -0,0 +1,1690 @@ +# ruff: noqa: TID251 + +"""Reusable widgets and modal screens for the Textual CLI.""" + +import asyncio +from collections.abc import Mapping, Sequence +from copy import deepcopy +from dataclasses import dataclass, replace +from itertools import islice +from math import ceil +from pathlib import Path + +import yaml +from pydantic import SecretStr, ValidationError +from rich.cells import cell_len, chop_cells +from rich.text import Text +from textual import events, on +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, Vertical, VerticalScroll +from textual.message import Message +from textual.screen import ModalScreen +from textual.timer import Timer +from textual.widgets import ( + Button, + Collapsible, + Input, + Markdown, + OptionList, + Select, + Static, + TabbedContent, + TabPane, + TextArea, +) +from textual.widgets._select import SelectCurrent, SelectOverlay +from textual.widgets.option_list import Option + +from ursa.agents.base import URSA_VERSION +from ursa.cli.config import ( + ChatModelConfig, + EmbModelConfig, + InferenceProviderConfig, + ModelConfig, +) +from ursa.cli.runtime import HITL +from ursa.cli.tui.agent_info import AgentDetails, ToolDetails, load_agent_tools +from ursa.cli.tui.helpers import _fuzzy_score +from ursa.cli.tui.tips import random_tip +from ursa.util.inference_providers import ( + ProviderModel, + list_provider_models, + sort_provider_models, + supported_model_providers, +) + + +class PromptArea(TextArea): + """A multiline editor whose bare Enter submits the current prompt.""" + + BINDINGS = [ + Binding( + "enter", "submit_prompt", "Submit prompt", show=False, priority=True + ), + Binding( + "shift+enter", + "insert_newline", + "Insert newline", + show=False, + priority=True, + ), + Binding( + "ctrl+j", + "insert_newline", + "Insert newline", + show=False, + priority=True, + ), + Binding( + "ctrl+c", "clear_prompt", "Clear prompt", show=False, priority=True + ), + Binding( + "up", + "history_up", + "Cursor or prompt history up", + show=False, + priority=True, + ), + Binding( + "down", + "history_down", + "Cursor or prompt history down", + show=False, + priority=True, + ), + Binding( + "alt+left,meta+left,alt+b", + "cursor_word_left", + "Cursor word left", + show=False, + priority=True, + ), + Binding( + "alt+right,meta+right,alt+f", + "cursor_word_right", + "Cursor word right", + show=False, + priority=True, + ), + Binding( + "@", + "file_macro", + "Choose workspace path", + show=False, + priority=True, + ), + Binding("#", "agent_macro", "Choose agent", show=False, priority=True), + Binding( + "/", + "command_macro", + "Open command picker", + show=False, + priority=True, + ), + ] + + class Submitted(Message): + def __init__(self, text: str) -> None: + super().__init__() + self.text = text + + class MacroTyped(Message): + """A macro trigger inserted by a real keyboard event.""" + + def __init__(self, trigger: str, location: tuple[int, int]) -> None: + super().__init__() + self.trigger = trigger + self.location = location + + def __init__(self) -> None: + super().__init__( + language="markdown", + soft_wrap=True, + tab_behavior="indent", + placeholder="Ask URSA… (@ files, # agents)", + id="prompt", + ) + self.prompt_history: list[str] = [] + self._history_index: int | None = None + + def on_mount(self) -> None: + key = ( + "Shift+Enter" + if self.app.preferred_newline_key == "shift+enter" + else "Ctrl+J" + ) + self.placeholder = f"Ask URSA… (@ files, # agents, {key} newline)" + + def _remember(self, text: str) -> None: + if text and ( + not self.prompt_history or self.prompt_history[-1] != text + ): + self.prompt_history.append(text) + self._history_index = None + + def _load_history(self, index: int) -> None: + self._history_index = index + self.load_text(self.prompt_history[index]) + self.move_cursor(( + len(self.document.lines) - 1, + len(self.document.lines[-1]), + )) + + def action_submit_prompt(self) -> None: + text = self.text.strip() + if text: + self._remember(text) + self.post_message(self.Submitted(text)) + + def action_insert_newline(self) -> None: + self.insert("\n") + + def action_clear_prompt(self) -> None: + self._remember(self.text) + self._history_index = len(self.prompt_history) + self.load_text("") + + def action_history_up(self) -> None: + if self.prompt_history and ( + not self.text or self.cursor_location[0] == 0 + ): + index = ( + len(self.prompt_history) + if self._history_index is None + else self._history_index + ) + self._load_history(max(0, index - 1)) + return + self.action_cursor_up() + + def action_history_down(self) -> None: + if self._history_index is not None: + next_index = self._history_index + 1 + if next_index < len(self.prompt_history): + self._load_history(next_index) + else: + self._history_index = None + self.load_text("") + return + self.action_cursor_down() + + def _insert_macro(self, trigger: str) -> None: + location = self.cursor_location + self.insert(trigger) + self.post_message(self.MacroTyped(trigger, location)) + + def action_file_macro(self) -> None: + self._insert_macro("@") + + def action_agent_macro(self) -> None: + self._insert_macro("#") + + def action_command_macro(self) -> None: + self._insert_macro("/") + + +class HotlistScreen(ModalScreen[str | None]): + """Fuzzy-searchable picker overlaid above the prompt.""" + + BINDINGS = [ + Binding("escape", "cancel", "Cancel picker", priority=True), + Binding("up", "previous_choice", "Previous choice", priority=True), + Binding("down", "next_choice", "Next choice", priority=True), + Binding("enter", "select_choice", "Select choice", priority=True), + ] + + def __init__(self, title: str, candidates: Sequence[str]) -> None: + super().__init__() + self.picker_title = title + self.candidates = list(candidates) + self.matches = list(candidates) + + def compose(self) -> ComposeResult: + with Vertical(id="hotlist"): + with Horizontal(id="hotlist-header"): + yield Static(self.picker_title, id="hotlist-title") + yield Static("Esc to Exit", id="hotlist-exit-hint") + yield Input(placeholder="fzf search…", id="hotlist-query") + yield OptionList( + *( + Option(candidate, id=str(index)) + for index, candidate in enumerate(self.matches) + ), + id="hotlist-options", + ) + + def on_mount(self) -> None: + self.query_one(Input).focus() + self._highlight_first() + + def action_previous_choice(self) -> None: + self.query_one(OptionList).action_cursor_up() + + def action_next_choice(self) -> None: + self.query_one(OptionList).action_cursor_down() + + def action_select_choice(self) -> None: + options = self.query_one(OptionList) + if options.option_count: + options.action_select() + + @on(Input.Changed) + def filter_options(self, event: Input.Changed) -> None: + ranked = [] + for index, candidate in enumerate(self.candidates): + score = _fuzzy_score(event.value, candidate) + if score is not None: + ranked.append((-score, index, candidate)) + ranked.sort() + self.matches = [candidate for _, _, candidate in ranked] + options = self.query_one(OptionList) + options.clear_options() + options.add_options(self.matches) + self._highlight_first() + + def _highlight_first(self) -> None: + options = self.query_one(OptionList) + options.highlighted = 0 if options.option_count else None + + @on(OptionList.OptionSelected) + def select_option(self, event: OptionList.OptionSelected) -> None: + self.dismiss(str(event.option.prompt)) + + def action_cancel(self) -> None: + self.dismiss(None) + + +class FuzzySelectOverlay(SelectOverlay): + """Select overlay whose type-to-search uses fuzzy matching.""" + + def __init__(self, type_to_search: bool = True) -> None: + super().__init__(type_to_search) + self._source_options: list[Option] = [] + + def set_source_options(self, options: Sequence[Option]) -> None: + self._source_options = list(options) + self.reset_search() + + def reset_search(self) -> None: + self._search_query = "" + self._show_matches() + + def _show_matches(self) -> None: + ranked: list[tuple[int, int, Option]] = [] + for index, option in enumerate(self._source_options): + prompt = option.prompt + candidate = ( + prompt.plain if isinstance(prompt, Text) else str(prompt) + ) + score = _fuzzy_score(self._search_query, candidate) + if score is not None: + ranked.append((-score, index, option)) + ranked.sort() + self.clear_options() + self.add_options(option for _, _, option in ranked) + self.highlighted = 0 if self.option_count else None + self.border_title = None + self.query_ancestor(FuzzySelect).show_search_query(self._search_query) + + async def _on_key(self, event: events.Key) -> None: + if event.key == "backspace": + event.stop() + event.prevent_default() + self._search_query = self._search_query[:-1] + self._show_matches() + elif event.character is not None and event.is_printable: + event.stop() + event.prevent_default() + self._search_query += event.character + self._show_matches() + + def watch_has_focus(self, value: bool) -> None: + if not value: + self.reset_search() + OptionList.watch_has_focus(self, value) + + def _find_search_match(self, query: str) -> int | None: + matches: list[tuple[int, int]] = [] + for index, option in enumerate(self._options): + prompt = option.prompt + candidate = ( + prompt.plain if isinstance(prompt, Text) else str(prompt) + ) + score = _fuzzy_score(query, candidate) + if score is not None: + matches.append((-score, index)) + return min(matches)[1] if matches else None + + def action_select(self) -> None: + if self.highlighted is None: + return + option = self.get_option_at_index(self.highlighted) + if not option.disabled and option.id is not None: + self.post_message(self.UpdateSelection(int(option.id))) + + +class FuzzySelect(Select): + """A Select with fuzzy type-to-search behavior.""" + + def compose(self) -> ComposeResult: + yield SelectCurrent(self.prompt) + yield FuzzySelectOverlay(type_to_search=self._type_to_search).data_bind( + compact=Select.compact + ) + + def _setup_options_renderables(self) -> None: + options = [ + Option(prompt, id=str(index)) + for index, (prompt, _value) in enumerate(self._options) + ] + self.query_one(FuzzySelectOverlay).set_source_options(options) + + def show_search_query(self, query: str) -> None: + current = self.query_one_optional(SelectCurrent) + if current is None: + return + if query: + current.update(query) + return + label = next( + (prompt for prompt, value in self._options if value == self.value), + self.NULL, + ) + current.update(label) + + def _watch_expanded(self, expanded: bool) -> None: + super()._watch_expanded(expanded) + if not expanded: + overlay = self.query_one_optional(FuzzySelectOverlay) + if overlay is not None: + overlay.reset_search() + + +class ThemeScreen(HotlistScreen): + """Theme picker that previews highlighted themes over the current app.""" + + def __init__( + self, + candidates: Sequence[str], + initial_theme: str, + ) -> None: + super().__init__("Themes", candidates) + self.initial_theme = initial_theme + + @on(OptionList.OptionHighlighted) + def preview_theme(self, event: OptionList.OptionHighlighted) -> None: + self.app.theme = str(event.option.prompt) + + def action_cancel(self) -> None: + self.app.theme = self.initial_theme + self.dismiss(None) + + +@dataclass(frozen=True) +class ModelSelection: + chat: ChatModelConfig + embedding: EmbModelConfig | None + + +class ModelFieldLabel(Horizontal): + """Compact field label with an accented help affordance.""" + + def __init__(self, label: str, help_text: str, *, id: str) -> None: + super().__init__(id=id, classes="model-field-label") + self.label = label + self.tooltip = help_text + + def compose(self) -> ComposeResult: + yield Static(self.label, classes="model-field-label-text") + yield Static("[", classes="model-field-help-bracket") + yield Static("?", classes="model-field-help-mark") + yield Static("]", classes="model-field-help-bracket") + + +class ModelScreen(ModalScreen[ModelSelection | None]): + """Configure chat and embedding model providers.""" + + BINDINGS = [ + Binding("escape", "cancel", "Cancel", priority=True), + Binding("ctrl+enter", "apply", "Apply", priority=True), + ] + CUSTOM_VALUE = "__ursa_custom__" + NONE_VALUE = "__ursa_none__" + YAML_VALIDATION_DELAY = 0.8 + STRUCTURED_FIELDS = ("model", "model_provider", "inference_provider") + + @staticmethod + def _validation_error_text(error: ValidationError) -> str: + """Render every Pydantic error compactly in the bounded error panel.""" + details = error.errors(include_url=False, include_context=False) + lines = [ + f"{len(details)} validation " + f"{'error' if len(details) == 1 else 'errors'} for {error.title}" + ] + for detail in details: + location = detail.get("loc", ()) + field = str(location[0]) if location else "configuration" + lines.append(f"{field}: {detail['msg']}") + return "\n".join(lines) + + FIELD_HELP = { + "model": ( + "The model identifier exposed by the provider, such as gpt-5.4 " + "or text-embedding-3-large. Not all models listed are valid chat " + "or embedding models." + ), + "model-provider": ( + "The LangChain model integration used to create the client, such " + "as openai, anthropic, google_genai, or ollama." + ), + "inference-provider": ( + "A named URSA inference provider supplying the endpoint, API key, " + "and TLS settings for this model. Update your config files to add " + "additional providers." + ), + } + + def __init__( + self, + providers: Mapping[str, InferenceProviderConfig], + chat: ChatModelConfig, + embedding: EmbModelConfig | None, + ) -> None: + super().__init__() + self.providers = dict(providers) + self.chat = chat + self.embedding_was_configured = embedding is not None + if embedding is None: + direct_settings = ( + { + "base_url": chat.base_url, + "api_key": deepcopy(chat.api_key), + "ssl_verify": chat.ssl_verify, + } + if chat.inference_provider is None + else {} + ) + embedding = EmbModelConfig( + model="", + model_provider=chat.model_provider, + inference_provider=chat.inference_provider, + **direct_settings, + ) + if embedding.inference_provider is not None: + embedding = embedding.resolve_inference_provider(self.providers) + self.embedding = embedding + self.drafts: dict[str, ModelConfig] = { + "chat": self.chat, + "embedding": self.embedding, + } + self._yaml_values = { + prefix: self._configured_values(config) + for prefix, config in self.drafts.items() + } + self.model_catalogs: dict[str, dict[str, ProviderModel]] = {} + self._model_load_generation = {"chat": 0, "embedding": 0} + self._yaml_timers: dict[str, Timer] = {} + self._syncing_controls = False + + @classmethod + def _choice_options(cls, values: Sequence[str]) -> list[tuple[str, str]]: + return [ + ("None", cls.NONE_VALUE), + *((value, value) for value in values), + ("Other…", cls.CUSTOM_VALUE), + ] + + @classmethod + def _editable_choice( + cls, + prefix: str, + field: str, + values: Sequence[str], + current: str, + ) -> tuple[Select, Input]: + choices = tuple(dict.fromkeys(value for value in values if value)) + listed = current in choices + selected = current if listed else cls.NONE_VALUE + if current and not listed: + selected = cls.CUSTOM_VALUE + select = FuzzySelect( + cls._choice_options(choices), + value=selected, + allow_blank=False, + id=f"{prefix}-{field}", + classes="model-editable-choice", + ) + custom = Input( + value="" if listed else current, + id=f"{prefix}-{field}-custom", + classes="model-custom-choice" + (" hidden" if listed else ""), + ) + return select, custom + + def _model_fields( + self, + prefix: str, + config: ModelConfig, + ) -> ComposeResult: + options = [ + ("None (direct model config)", self.NONE_VALUE), + *( + ( + f"{name} ({getattr(provider, 'base_url', None) or 'default'})", + name, + ) + for name, provider in sorted(self.providers.items()) + ), + ] + selected_provider = config.inference_provider + if ( + selected_provider is None + and prefix == "embedding" + and not self.embedding_was_configured + ): + selected_provider = next(iter(self.providers), None) + selected_provider = selected_provider or self.NONE_VALUE + yield self._field_label("Model", "model", prefix) + model_select, custom_model = self._editable_choice( + prefix, "model-name", (config.model,), config.model + ) + yield model_select + yield custom_model + yield self._field_label("Model provider", "model-provider", prefix) + model_providers = supported_model_providers( + "embedding" if prefix == "embedding" else "chat" + ) + selected_model_provider = config.model_provider or self.NONE_VALUE + yield Select( + [ + ("None", self.NONE_VALUE), + *((provider, provider) for provider in model_providers), + ], + value=selected_model_provider, + allow_blank=False, + id=f"{prefix}-model-provider", + ) + yield self._field_label( + "Inference provider", "inference-provider", prefix + ) + yield Select( + options, + value=selected_provider, + allow_blank=False, + id=f"{prefix}-inference-provider", + classes="inference-provider-choice", + ) + + @classmethod + def _field_label( + cls, label: str, field: str, prefix: str + ) -> ModelFieldLabel: + return ModelFieldLabel( + label, + cls.FIELD_HELP[field], + id=f"{prefix}-{field}-label", + ) + + def compose(self) -> ComposeResult: + with Vertical(classes="settings-dialog"): + yield Static("Models", classes="settings-title") + with TabbedContent(): + with TabPane("Chat", id="chat-model-tab"): + yield from self._model_fields( + "chat", + self.chat, + ) + yield from self._advanced_editor("chat", self.chat) + with TabPane("Embedding", id="embedding-model-tab"): + yield from self._model_fields( + "embedding", + self.embedding, + ) + yield from self._advanced_editor( + "embedding", self.embedding + ) + with Horizontal(classes="settings-actions"): + yield Button("Cancel", id="model-cancel") + yield Button("Apply", id="model-apply", variant="primary") + + def _advanced_editor( + self, prefix: str, config: ModelConfig + ) -> ComposeResult: + with Collapsible( + title="Advanced", + collapsed=True, + id=f"{prefix}-advanced", + classes="model-advanced", + ): + yield TextArea( + self._dump_yaml(config), + language="yaml", + show_line_numbers=True, + tab_behavior="indent", + id=f"{prefix}-config-yaml", + classes="model-yaml-editor", + ) + yield Static( + "", + id=f"{prefix}-yaml-error", + classes="model-yaml-error hidden", + ) + + @staticmethod + def _configured_values(config: ModelConfig) -> dict: + """Return editable values without materializing inherited defaults.""" + dumped = config.model_dump( + mode="json", + exclude_unset=True, + context={"include_defaults": False}, + ) + dumped.setdefault("model", config.model) + return dumped + + @classmethod + def _dump_yaml(cls, config: ModelConfig) -> str: + return cls._dump_yaml_values(cls._configured_values(config)) + + @staticmethod + def _dump_yaml_values(values: Mapping) -> str: + return yaml.safe_dump( + dict(values), + sort_keys=False, + allow_unicode=True, + ) + + def _yaml_text(self, prefix: str) -> str: + return self._dump_yaml_values(self._yaml_values[prefix]) + + def _update_yaml_values(self, prefix: str, config: ModelConfig) -> None: + """Patch structured fields without reordering the YAML mapping.""" + configured = self._configured_values(config) + values = self._yaml_values[prefix] + if config.inference_provider is not None: + values.pop("base_url", None) + for field in self.STRUCTURED_FIELDS: + if field in configured: + values[field] = configured[field] + else: + values.pop(field, None) + + def on_mount(self) -> None: + self.query_one("#chat-model-name", Select).focus() + chat_generation = self._next_model_load_generation("chat") + embedding_generation = self._next_model_load_generation("embedding") + self.run_worker( + self._load_initial_models(chat_generation, embedding_generation), + group="model-discovery", + exclusive=True, + ) + + def on_unmount(self) -> None: + for timer in self._yaml_timers.values(): + timer.stop() + self._yaml_timers.clear() + + def _next_model_load_generation(self, prefix: str) -> int: + self._model_load_generation[prefix] += 1 + return self._model_load_generation[prefix] + + async def _load_initial_models( + self, chat_generation: int, embedding_generation: int + ) -> None: + await self._load_models("chat", self.chat, chat_generation) + await self._load_models( + "embedding", self.embedding, embedding_generation + ) + + def _request_model_load( + self, + prefix: str, + config: ModelConfig | InferenceProviderConfig, + ): + generation = self._next_model_load_generation(prefix) + return self.run_worker( + self._load_models(prefix, config, generation), + group=f"{prefix}-models", + exclusive=True, + ) + + async def _load_models( + self, + prefix: str, + config: ModelConfig | InferenceProviderConfig, + generation: int, + ) -> None: + try: + models = await asyncio.to_thread(list_provider_models, config) + except Exception as exc: # noqa: BLE001 + if generation != self._model_load_generation[prefix]: + return + self.model_catalogs[prefix] = {} + current = self._choice_value(prefix, "model-name") + self._set_model_options(prefix, {}, current) + self.notify( + f"Unable to list models: {exc}", + title="Model discovery", + severity="warning", + ) + return + if generation != self._model_load_generation[prefix]: + return + models = sort_provider_models( + models, "embedding" if prefix == "embedding" else "chat" + ) + catalog = {model.name: model for model in models} + self.model_catalogs[prefix] = catalog + current = self._choice_value(prefix, "model-name") + self._set_model_options(prefix, catalog, current) + + def _set_model_options( + self, + prefix: str, + catalog: Mapping[str, ProviderModel], + current: str, + ) -> None: + """Render a catalog while retaining an unavailable current model.""" + options = self._choice_options(tuple(catalog)) + if current and current not in catalog: + options.insert(-1, (f"Not found: {current}", current)) + select = self.query_one(f"#{prefix}-model-name", Select) + was_syncing = self._syncing_controls + self._syncing_controls = True + try: + with self.prevent(Select.Changed): + select.set_options(options) + select.value = current or self.NONE_VALUE + custom = self.query_one(f"#{prefix}-model-name-custom", Input) + custom.set_class(select.value != self.CUSTOM_VALUE, "hidden") + finally: + self._syncing_controls = was_syncing + + def _choice_value(self, prefix: str, field: str) -> str: + select = self.query_one(f"#{prefix}-{field}", Select) + if select.value == self.NONE_VALUE: + return "" + if select.value != self.CUSTOM_VALUE: + return str(select.value) + return self.query_one(f"#{prefix}-{field}-custom", Input).value.strip() + + @on(Select.Changed, ".model-editable-choice") + def show_custom_choice(self, event: Select.Changed) -> None: + if event.select.id is None: + return + custom = self.query_one(f"#{event.select.id}-custom", Input) + custom.set_class(event.value != self.CUSTOM_VALUE, "hidden") + + prefix = event.select.id.removesuffix("-model-name") + record = self.model_catalogs.get(prefix, {}).get(str(event.value)) + if record is None or record.model_provider is None: + self._structured_controls_changed(prefix) + return + provider = record.model_provider + provider_select = self.query_one(f"#{prefix}-model-provider", Select) + model_type = "embedding" if prefix == "embedding" else "chat" + if provider in supported_model_providers(model_type): + provider_select.value = provider + self._structured_controls_changed(prefix) + + @on(Select.Changed, ".inference-provider-choice") + def update_models_for_inference_provider( + self, event: Select.Changed + ) -> None: + if event.select.id is None or not isinstance(event.value, str): + return + prefix = event.select.id.removesuffix("-inference-provider") + self._structured_controls_changed(prefix) + config = self.providers.get(event.value, self.drafts[prefix]) + self._request_model_load(prefix, config) + + @on(Select.Changed, "#chat-model-provider, #embedding-model-provider") + def model_provider_changed(self, event: Select.Changed) -> None: + if event.select.id is not None: + self._structured_controls_changed( + event.select.id.removesuffix("-model-provider") + ) + + @on(Input.Changed, ".model-custom-choice") + def custom_model_changed(self, event: Input.Changed) -> None: + if event.input.id is not None: + self._structured_controls_changed( + event.input.id.removesuffix("-model-name-custom") + ) + + def _structured_controls_changed(self, prefix: str) -> None: + if self._syncing_controls or not self.is_mounted: + return + original = self.drafts[prefix] + try: + updated = self._settings(prefix, original) + except ValueError: + return + self.drafts[prefix] = updated + self._update_yaml_values(prefix, updated) + editor = self.query_one(f"#{prefix}-config-yaml", TextArea) + text = self._yaml_text(prefix) + if editor.text != text: + self._syncing_controls = True + try: + with self.prevent(TextArea.Changed): + editor.text = text + finally: + self._syncing_controls = False + self._validate_yaml(prefix, update_controls=False) + + @on(TextArea.Changed, ".model-yaml-editor") + def yaml_changed(self, event: TextArea.Changed) -> None: + if self._syncing_controls or event.text_area.id is None: + return + prefix = event.text_area.id.removesuffix("-config-yaml") + if event.text_area.text == self._yaml_text(prefix): + self._set_yaml_state(prefix, "valid") + return + self._set_yaml_state(prefix, "neutral") + timer = self._yaml_timers.pop(prefix, None) + if timer is not None: + timer.stop() + self._yaml_timers[prefix] = self.set_timer( + self.YAML_VALIDATION_DELAY, + lambda: self._validate_yaml(prefix, update_controls=True), + ) + + def _validate_yaml( + self, prefix: str, *, update_controls: bool + ) -> ModelConfig | None: + editor = self.query_one(f"#{prefix}-config-yaml", TextArea) + config_type = ChatModelConfig if prefix == "chat" else EmbModelConfig + try: + values = yaml.safe_load(editor.text) + if not isinstance(values, dict): + raise ValueError("Configuration must be a YAML mapping") + validation_values = deepcopy(values) + current_api_key = self.drafts[prefix].api_key + if isinstance(current_api_key, SecretStr) and values.get( + "api_key" + ) == str(current_api_key): + validation_values["api_key"] = deepcopy(current_api_key) + config = config_type.model_validate(validation_values) + if not config.model.strip() and (prefix == "chat" or config.model): + model_type = "Chat" if prefix == "chat" else "Embedding" + raise ValueError(f"{model_type} model must not be blank") + if ( + config.inference_provider is not None + and config.inference_provider not in self.providers + ): + raise ValueError( + f"Unknown inference_provider '{config.inference_provider}'" + ) + except (yaml.YAMLError, ValueError) as exc: + error = ( + self._validation_error_text(exc) + if isinstance(exc, ValidationError) + else str(exc) + ) + self._set_yaml_state(prefix, "invalid", error) + return None + self.drafts[prefix] = config + self._yaml_values[prefix] = deepcopy(values) + self._set_yaml_state(prefix, "valid") + if update_controls: + self._update_controls_from_config(prefix, config) + return config + + def _set_yaml_state(self, prefix: str, state: str, error: str = "") -> None: + editor = self.query_one(f"#{prefix}-config-yaml", TextArea) + editor.remove_class("yaml-valid", "yaml-invalid") + if state != "neutral": + editor.add_class(f"yaml-{state}") + message = self.query_one(f"#{prefix}-yaml-error", Static) + message.update(Text(error)) + message.set_class(not error, "hidden") + + def _update_controls_from_config( + self, prefix: str, config: ModelConfig + ) -> None: + inference_select = self.query_one( + f"#{prefix}-inference-provider", Select + ) + previous_inference_provider = inference_select.value + self._syncing_controls = True + try: + with self.prevent(Select.Changed): + catalog = self.model_catalogs.get(prefix, {}) + self._set_model_options(prefix, catalog, config.model) + + model_provider = config.model_provider or self.NONE_VALUE + provider_select = self.query_one( + f"#{prefix}-model-provider", Select + ) + model_type = "embedding" if prefix == "embedding" else "chat" + options = [ + ("None", self.NONE_VALUE), + *( + (provider, provider) + for provider in supported_model_providers(model_type) + ), + ] + available = {value for _, value in options} + if model_provider not in available: + options.append(( + f"Not found: {model_provider}", + model_provider, + )) + provider_select.set_options(options) + provider_select.value = model_provider + inference_provider = ( + config.inference_provider or self.NONE_VALUE + ) + inference_select.value = inference_provider + finally: + self._syncing_controls = False + if inference_provider != previous_inference_provider: + provider_config = self.providers.get( + str(inference_provider), config + ) + self._request_model_load(prefix, provider_config) + + def _settings(self, prefix: str, original: ModelConfig) -> ModelConfig: + model_name = self._choice_value(prefix, "model-name") + model_provider_value = self.query_one( + f"#{prefix}-model-provider", Select + ).value + model_provider = ( + str(model_provider_value) + if isinstance(model_provider_value, str) + and model_provider_value != self.NONE_VALUE + else "" + ) + advertised = self.model_catalogs.get(prefix, {}).get(model_name) + if ( + not model_provider + and advertised is not None + and advertised.model_provider is not None + ): + model_provider = advertised.model_provider + provider_value = self.query_one( + f"#{prefix}-inference-provider", Select + ).value + inference_provider = ( + provider_value + if isinstance(provider_value, str) + and provider_value != self.NONE_VALUE + else "" + ) + dumped = original.model_dump(mode="python") + configured = { + name: deepcopy(value) + for name, value in dumped.items() + if name in original.model_fields_set + } + for field in ("model", "model_provider", "inference_provider"): + configured.pop(field, None) + if inference_provider: + # A named provider owns its endpoint. This mirrors ModelConfig's + # merge semantics when switching away from a direct endpoint. + configured.pop("base_url", None) + updates = { + "model": model_name, + "inference_provider": inference_provider or None, + } + if ( + "model_provider" in original.model_fields_set + or model_provider != original.model_provider + ): + updates["model_provider"] = model_provider or None + return type(original).model_validate({**configured, **updates}) + + @on(Button.Pressed, "#model-apply") + def apply(self) -> None: + self.action_apply() + + def action_apply(self) -> None: + try: + for prefix in ("chat", "embedding"): + editor = self.query_one(f"#{prefix}-config-yaml", TextArea) + draft = self.drafts[prefix] + # If YAML has not diverged from the draft, fold in any control + # event still queued in Textual before doing final validation. + if editor.text == self._yaml_text(prefix): + configured = self._settings(prefix, draft) + self.drafts[prefix] = configured + self._update_yaml_values(prefix, configured) + editor.text = self._yaml_text(prefix) + except ValueError as exc: + self.notify( + str(exc), + title="Model not changed", + severity="error", + timeout=10, + markup=False, + ) + return + + chat = self._validate_yaml("chat", update_controls=False) + embedding = self._validate_yaml("embedding", update_controls=False) + if chat is None or embedding is None: + error = next( + ( + str(message.content) + for message in self.query(".model-yaml-error") + if str(message.content) + ), + "Invalid YAML configuration", + ) + self.notify( + error, + title="Model not changed", + severity="error", + timeout=10, + markup=False, + ) + return + if not chat.model: + self.notify("Chat model is required", severity="error") + return + assert isinstance(chat, ChatModelConfig) + if not embedding.model: + embedding = None + assert embedding is None or isinstance(embedding, EmbModelConfig) + self.dismiss(ModelSelection(chat, embedding)) + + @on(Button.Pressed, "#model-cancel") + def cancel_button(self) -> None: + self.action_cancel() + + def action_cancel(self) -> None: + expanded = list(self.query("FuzzySelect.-expanded")) + if expanded: + for select in expanded: + select.expanded = False + return + self.dismiss(None) + + def on_click(self, event: events.Click) -> None: + if event.widget is None: + return + ancestors = set(event.widget.ancestors_with_self) + for select in self.query("FuzzySelect.-expanded"): + if select not in ancestors: + select.expanded = False + + +class InformationScreen(ModalScreen[None]): + """Scrollable command output displayed without leaving the application.""" + + BINDINGS = [ + Binding("escape,q", "close", "Close", priority=True), + Binding("up", "scroll_up", "Scroll up"), + Binding("down", "scroll_down", "Scroll down"), + Binding("home", "scroll_home", "Scroll to top"), + Binding("end", "scroll_end", "Scroll to bottom"), + Binding("pageup", "page_up", "Page up"), + Binding("pagedown", "page_down", "Page down"), + ] + + def __init__( + self, + title: str, + content: str, + *, + config_yaml: str | None = None, + ) -> None: + super().__init__() + self.screen_title = title + self.content = content + self.config_yaml = config_yaml + + def compose(self) -> ComposeResult: + with Vertical(id="information"): + yield Static(self.screen_title, id="information-title") + if self.config_yaml is None: + yield VerticalScroll( + Markdown(self.content), id="information-body" + ) + else: + with TabbedContent(id="status-tabs"): + with TabPane("Status", id="status-summary-tab"): + yield VerticalScroll( + Markdown(self.content), id="information-body" + ) + with TabPane("Config", id="status-config-tab"): + yield Static( + "Read only — select text to copy", + id="status-config-readonly", + ) + yield TextArea( + self.config_yaml, + language="yaml", + read_only=True, + show_line_numbers=True, + id="status-config-yaml", + ) + + def action_close(self) -> None: + self.dismiss(None) + + def _scroll_view(self) -> VerticalScroll: + return self.query_one("#information-body", VerticalScroll) + + def action_scroll_up(self) -> None: + self._scroll_view().action_scroll_up() + + def action_scroll_down(self) -> None: + self._scroll_view().action_scroll_down() + + def action_scroll_home(self) -> None: + self._scroll_view().action_scroll_home() + + def action_scroll_end(self) -> None: + self._scroll_view().action_scroll_end() + + def action_page_up(self) -> None: + self._scroll_view().action_page_up() + + def action_page_down(self) -> None: + self._scroll_view().action_page_down() + + +def _markdown_cell(value: object) -> str: + """Escape a value for a compact Markdown table cell.""" + return str(value).replace("`", "\\`").replace("|", "\\|").replace("\n", " ") + + +def _tool_markdown(tool: ToolDetails) -> str: + """Render expanded tool metadata inspired by the legacy Rich report.""" + return "\n\n".join( + filter( + None, + ( + tool.description, + "\n".join([ + "| Setting | Value |", + "|---|---|", + *( + [ + "| Source | " + f"`MCP: {_markdown_cell(tool.mcp_server)}` |" + ] + if tool.mcp_server + else [] + ), + f"| Class | `{_markdown_cell(tool.class_name)}` |", + f"| Args schema | `{_markdown_cell(tool.schema_name)}` |", + "| Return directly | " + f"`{_markdown_cell(tool.return_direct)}` |", + ]), + ( + "\n".join([ + "### Arguments", + "", + "| Name | Type | Required | Description |", + "|---|---|---|---|", + *( + "| " + f"`{_markdown_cell(argument.name)}` | " + f"`{_markdown_cell(argument.type_name)}` | " + f"{'yes' if argument.required else 'no'} | " + f"{_markdown_cell(argument.description)} |" + for argument in tool.arguments + ), + ]) + if tool.arguments + else "" + ), + ), + ) + ) + + +class AgentToolDetails(Collapsible): + """A tool card which builds its Markdown only when first expanded.""" + + def __init__(self, tool: ToolDetails) -> None: + title = ( + f"{tool.name} (mcp: {tool.mcp_server})" + if tool.mcp_server + else tool.name + ) + super().__init__( + title=title, + collapsed=True, + classes="agent-tool", + ) + self.tool = tool + self._details_mounted = False + + async def on_collapsible_expanded( + self, event: Collapsible.Expanded + ) -> None: + if event.collapsible is not self or self._details_mounted: + return + self._details_mounted = True + try: + await self.query_one(Collapsible.Contents).mount( + Markdown(_tool_markdown(self.tool)) + ) + except BaseException: + self._details_mounted = False + raise + + +class AgentsScreen(InformationScreen): + """Tabbed descriptions and configured-tool details for all agents.""" + + def __init__(self, agents: tuple[AgentDetails, ...], hitl: HITL) -> None: + super().__init__("Agents", "") + self.agents = agents + self.hitl = hitl + self._tool_loads_started: set[str] = set() + self._tool_loading_frames: dict[int, int] = {} + self._tool_loading_timers: dict[int, Timer] = {} + self._tool_panes_pending_render: set[int] = set(range(1, len(agents))) + self._tool_activations_started: set[int] = set() + + def compose(self) -> ComposeResult: + with Vertical(id="information"): + yield Static(self.screen_title, id="information-title") + with TabbedContent(id="agents-tabs"): + for index, agent in enumerate(self.agents): + with TabPane(f"#{agent.name}", id=f"agent-tab-{index}"): + with VerticalScroll(classes="agent-details"): + yield Markdown(agent.description) + if agent.config: + yield Markdown( + "\n".join([ + "### Configuration", + "", + "| Option | Value |", + "|---|---|", + *( + f"| `{_markdown_cell(key)}` | " + f"`{_markdown_cell(value)}` |" + for key, value in agent.config + ), + ]) + ) + yield Static( + "Configured tools", classes="agent-tools-title" + ) + with Vertical( + id=f"agent-tools-{index}", + classes="agent-tools", + ): + if index == 0: + yield from self._tool_widgets(agent) + else: + yield Static( + "Select this tab to display its tools.", + classes="agent-tools-empty", + ) + + @staticmethod + def _tool_widgets(agent: AgentDetails): + if not agent.tools_loaded and not agent.tools: + yield Static( + "Tools have not yet been loaded.", + classes="agent-tools-empty", + ) + elif agent.tool_error: + yield Static( + Text("Unable to load tools: " + agent.tool_error), + classes="agent-tools-error", + ) + return + elif not agent.tools: + yield Static("No configured tools.", classes="agent-tools-empty") + for tool in agent.tools: + yield AgentToolDetails(tool) + + @on(TabbedContent.TabActivated, "#agents-tabs") + def _load_active_agent_tools( + self, event: TabbedContent.TabActivated + ) -> None: + if event.pane.id is None: + return + index = int(event.pane.id.rsplit("-", 1)[-1]) + agent = self.agents[index] + if index in self._tool_activations_started or ( + agent.tools_loaded and index not in self._tool_panes_pending_render + ): + return + self._tool_activations_started.add(index) + self.run_worker( + self._activate_agent_tools(index), + group=f"agent-tools-activation-{index}", + ) + + async def _activate_agent_tools(self, index: int) -> None: + agent = self.agents[index] + try: + if index in self._tool_panes_pending_render: + if not await self._render_agent_tools_safely(index, agent): + return + self._tool_panes_pending_render.discard(index) + # Rendering may replace this snapshot with a terminal error. + agent = self.agents[index] + if agent.tools_loaded or agent.name in self._tool_loads_started: + return + self._tool_loads_started.add(agent.name) + container = self.query_one(f"#agent-tools-{index}", Vertical) + if not agent.tools: + await container.remove_children() + loading = Static("Fetching tools.", classes="agent-tools-loading") + await container.mount( + loading, + before=container.children[0] if container.children else None, + ) + self._tool_loading_frames[index] = 1 + self._tool_loading_timers[index] = self.set_interval( + 0.3, lambda: self._advance_tool_loading(index) + ) + container.scroll_visible(animate=False, top=True, immediate=True) + await self._hydrate_tools(index) + finally: + self._tool_activations_started.discard(index) + + def _advance_tool_loading(self, index: int) -> None: + loading = self.query(f"#agent-tools-{index} .agent-tools-loading") + frame = self._tool_loading_frames.get(index) + if not loading or frame is None: + return + loading.first(Static).update(f"Fetching tools{'.' * (frame + 1)}") + self._tool_loading_frames[index] = (frame + 1) % 3 + + def _stop_tool_loading(self, index: int) -> None: + timer = self._tool_loading_timers.pop(index, None) + if timer is not None: + timer.stop() + self._tool_loading_frames.pop(index, None) + + async def _hydrate_tools(self, index: int) -> None: + agent = self.agents[index] + try: + tools = await load_agent_tools(self.hitl, agent.name) + updated = replace( + agent, + tools=tools, + tools_loaded=True, + tool_error="", + ) + except asyncio.CancelledError: + self._tool_loads_started.discard(agent.name) + self._stop_tool_loading(index) + raise + except Exception as exc: # keep the browser usable on provider failure + updated = replace( + agent, + tools=(), + tools_loaded=True, + tool_error=f"{type(exc).__name__}: {exc}", + ) + agents = list(self.agents) + agents[index] = updated + self.agents = tuple(agents) + if not self.is_mounted: + self._stop_tool_loading(index) + return + tabs = self.query_one("#agents-tabs", TabbedContent) + if tabs.active != f"agent-tab-{index}": + # Rendering a large collection of Markdown tool cards is UI-thread + # work. Do not freeze whichever tab the user switched to merely to + # update an invisible pane. + self._tool_panes_pending_render.add(index) + self._stop_tool_loading(index) + return + try: + rendered = await self._render_agent_tools_safely(index, updated) + finally: + self._stop_tool_loading(index) + if not rendered: + self._tool_panes_pending_render.add(index) + + async def _render_agent_tools( + self, index: int, agent: AgentDetails + ) -> bool: + container = self.query_one(f"#agent-tools-{index}", Vertical) + widgets = iter(self._tool_widgets(agent)) + tab_id = f"agent-tab-{index}" + if self.query_one("#agents-tabs", TabbedContent).active != tab_id: + return False + first_batch = list(islice(widgets, 1)) + if self.query_one("#agents-tabs", TabbedContent).active != tab_id: + return False + with self.app.batch_update(): + await container.remove_children() + await container.mount(*first_batch) + while True: + if self.query_one("#agents-tabs", TabbedContent).active != tab_id: + return False + batch = list(islice(widgets, 1)) + if not batch: + break + with self.app.batch_update(): + await container.mount(*batch) + # Let queued key and tab events preempt a large tool collection. + await asyncio.sleep(0) + if self.query_one("#agents-tabs", TabbedContent).active != tab_id: + return False + container.scroll_visible(animate=False, top=True, immediate=True) + return True + + async def _render_agent_tools_safely( + self, index: int, agent: AgentDetails + ) -> bool: + try: + return await self._render_agent_tools(index, agent) + except asyncio.CancelledError: + raise + except Exception as exc: + failed = replace( + agent, + tools=(), + tools_loaded=True, + tool_error=f"{type(exc).__name__}: {exc}", + ) + agents = list(self.agents) + agents[index] = failed + self.agents = tuple(agents) + self._tool_panes_pending_render.add(index) + container = self.query_one(f"#agent-tools-{index}", Vertical) + await container.remove_children() + await container.mount(*self._tool_widgets(failed)) + self._tool_panes_pending_render.discard(index) + return True + + def _scroll_view(self) -> VerticalScroll: + tabs = self.query_one(TabbedContent) + return self.query_one(f"#{tabs.active} .agent-details", VerticalScroll) + + +class WelcomeBanner(Vertical): + """URSA logo, active configuration snapshot, and a concise usage tip.""" + + LOGO = r""" __ ________________ _ + / / / / ___/ ___/ __ `/ +/ /_/ / / (__ ) /_/ / +\__,_/_/ /____/\__,_/""" + + def __init__(self, hitl: HITL) -> None: + super().__init__(id="welcome") + self.hitl = hitl + workspace = Path(self.hitl.workspace).resolve() + try: + relative = workspace.relative_to(Path.home()) + self.workspace_text = str(Path("~") / relative) + except ValueError: + self.workspace_text = str(workspace) + self.version_text = f"v{URSA_VERSION}" + self.tip = "" + + @staticmethod + def _fit_middle(text: str, width: int) -> str: + if width <= 0 or cell_len(text) <= width: + return text + if width == 1: + return "…" + available = width - 1 + left = available // 3 + right = available - left + prefix = chop_cells(text, left)[0] + suffix = chop_cells(text[::-1], right)[0][::-1] + return f"{prefix}…{suffix}" + + def _fit_metadata(self) -> None: + version = self.query_one("#welcome-version", Static) + workspace_row = self.query_one("#welcome-workspace-row") + workspace = self.query_one("#welcome-workspace", Static) + version.update( + Text( + self._fit_middle( + self.version_text, version.content_region.width + ) + ) + ) + row_width = workspace_row.content_region.width + inline = ( + cell_len("Workspace") + 2 + cell_len(self.workspace_text) + <= row_width + ) + workspace_row.set_class(inline, "workspace-inline") + workspace_row.set_class(not inline, "workspace-stacked") + workspace_width = row_width - 11 if inline else row_width + workspace.update( + Text(self._fit_middle(self.workspace_text, workspace_width)) + ) + + def on_mount(self) -> None: + self.tip = random_tip( + self.app, + (type(self.app), PromptArea, HotlistScreen), + ) + self.query_one("#welcome-tip", Static).update(f"Tip: {self.tip}") + self._fit_metadata() + + def on_resize(self) -> None: + self._fit_metadata() + + def _config_snapshot(self) -> Text: + embedding = self.hitl.config.emb_model + return Text( + "\n".join([ + f"LLM {self.hitl.config.llm_model.pretty_repr()}", + f"Embedding {embedding.pretty_repr() if embedding else 'none'}", + f"Group {getattr(self.hitl, 'group', None) or 'default'}", + ]) + ) + + def refresh_config(self) -> None: + """Refresh the displayed runtime configuration snapshot.""" + self.query_one("#welcome-config-values", Static).update( + self._config_snapshot() + ) + + def compose(self) -> ComposeResult: + with Horizontal(id="welcome-top"): + with Vertical(id="welcome-logo"): + with Vertical(id="welcome-logo-stack"): + yield Static(self.LOGO, id="welcome-logo-art") + yield Static(self.version_text, id="welcome-version") + with Vertical(id="welcome-config"): + with Vertical(id="welcome-workspace-row"): + yield Static("Workspace", id="welcome-workspace-label") + yield Static( + Text(self.workspace_text), + id="welcome-workspace", + ) + yield Static( + self._config_snapshot(), id="welcome-config-values" + ) + yield Static( + f"Tip: {self.tip}", + id="welcome-tip", + ) + + +class MessageCard(Static): + def __init__(self, role: str, content: str) -> None: + super().__init__(classes=f"message-card {role}") + self.role = role + self.content = content + + def compose(self) -> ComposeResult: + if self.role == "assistant": + yield Static("URSA", classes="message-role") + yield Markdown(self.content, classes="message-body") + + @on(Markdown.TableOfContentsUpdated) + def remove_trailing_markdown_margin( + self, event: Markdown.TableOfContentsUpdated + ) -> None: + blocks = list(event.markdown.children) + if blocks: + blocks[-1].styles.margin = 0 + + +class ToolMessage(Horizontal): + """A neutral transcript entry for application-level activity.""" + + def __init__(self, content: str) -> None: + super().__init__(classes="tool-message") + self.content = content + + def compose(self) -> ComposeResult: + yield Static("●", classes="tool-message-mark") + yield Static(Text(self.content), classes="tool-message-body") + + +class ActivityIndicator(Horizontal): + """Animated, event-driven status for one conversation turn.""" + + FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏") + + def __init__(self) -> None: + super().__init__(classes="activity") + self._frame = 0 + self._timer = None + + def compose(self) -> ComposeResult: + yield Static(self.FRAMES[0], classes="activity-spinner") + yield Static("Thinking…", classes="activity-text") + yield Static("", classes="activity-done-mark") + + def on_mount(self) -> None: + self._timer = self.set_interval(0.08, self._advance) + + def _advance(self) -> None: + self.query_one(".activity-spinner", Static).update( + self.FRAMES[self._frame] + ) + self._frame = (self._frame + 1) % len(self.FRAMES) + + def update_message(self, message: str) -> None: + message = " ".join(str(message).split()) + if message: + self.query_one(".activity-text", Static).update( + Text(message[-500:]) + ) + + def finish(self, *, elapsed: float, tokens: int) -> None: + if self._timer is not None: + self._timer.pause() + self.query_one(".activity-spinner", Static).update("") + if elapsed <= 30: + self.query_one(".activity-text", Static).update("") + self.query_one(".activity-done-mark", Static).update("") + self.remove_class("done") + self.add_class("hidden") + return + seconds = ceil(elapsed) + if seconds < 60: + duration = f"{seconds}s" + else: + minutes, seconds = divmod(seconds, 60) + duration = f"{minutes}m {seconds:02d}s" + self.query_one(".activity-text", Static).update( + f"Done in {duration} and {tokens:,} tokens" + ) + self.query_one(".activity-done-mark", Static).update("✓") + self.remove_class("hidden") + self.add_class("done") diff --git a/src/ursa/prompt_library/chatter_prompts.py b/src/ursa/prompt_library/chatter_prompts.py index a31f931f..311110ce 100644 --- a/src/ursa/prompt_library/chatter_prompts.py +++ b/src/ursa/prompt_library/chatter_prompts.py @@ -3,7 +3,5 @@ def get_chatter_system_prompt(): You are the chat interface to URSA, a flexible agentic workflow for accelerating scientific tasks. Do not speculate about the capabilities of URSA beyond the information given to you. The documentation for URSA is available at https://lanl.github.io/ursa/. - The user may view a list of commands by typing `?` or `help`. - The user may view help for a specific command by typing `help` followed by the name of the command. """ diff --git a/src/ursa/tools/search_tools.py b/src/ursa/tools/search_tools.py index cd848f25..46d45bec 100644 --- a/src/ursa/tools/search_tools.py +++ b/src/ursa/tools/search_tools.py @@ -38,7 +38,7 @@ async def run_arxiv_search( summaries_path=Path("./arxiv_summaries"), download=True, ) - events.emit( + await events.aemit( "Searching ArXiv", stage="search", query=query, @@ -52,7 +52,7 @@ async def run_arxiv_search( ) arxiv_result = arxiv_result["final_summary"] - events.emit( + await events.aemit( "ArXiv search complete", stage="search_result", query=query, @@ -60,7 +60,7 @@ async def run_arxiv_search( ) return f"[ArXiv Agent Output]:\n {arxiv_result}" except Exception as e: # noqa: BLE001 - events.emit( + await events.aemit( "ArXiv search failed", stage="search", phase="error", @@ -106,7 +106,7 @@ async def run_web_search( summaries_path=Path("./web_summaries"), download=True, ) - events.emit( + await events.aemit( "Searching Web", stage="search", query=query, @@ -120,7 +120,7 @@ async def run_web_search( ) web_result = web_result["final_summary"] - events.emit( + await events.aemit( "Web search complete", stage="search_result", query=query, @@ -128,7 +128,7 @@ async def run_web_search( ) return f"[Web Search Agent Output]:\n {web_result}" except Exception as e: # noqa: BLE001 - events.emit( + await events.aemit( "Web search failed", stage="search", phase="error", @@ -175,7 +175,7 @@ async def run_osti_search( vectorstore_path=Path("./osti_vectorstores"), download=True, ) - events.emit( + await events.aemit( "Searching OSTI.gov", stage="search", query=query, @@ -189,7 +189,7 @@ async def run_osti_search( ) osti_result = osti_result["final_summary"] - events.emit( + await events.aemit( "OSTI.gov search complete", stage="search_result", query=query, @@ -197,7 +197,7 @@ async def run_osti_search( ) return f"[OSTI Agent Output]:\n {osti_result}" except Exception as e: # noqa: BLE001 - events.emit( + await events.aemit( "OSTI.gov search failed", stage="search", phase="error", diff --git a/src/ursa/util/crossplatform.py b/src/ursa/util/crossplatform.py index 279ef240..d5cbd138 100644 --- a/src/ursa/util/crossplatform.py +++ b/src/ursa/util/crossplatform.py @@ -1,6 +1,7 @@ -"""Cross-platform locations used by URSA.""" - import os +import shlex +import shutil +import subprocess import sys from pathlib import Path @@ -46,3 +47,82 @@ def user_config_paths() -> list[Path]: if candidate not in paths: paths.append(candidate) return paths + + +SSH_ENV_VARS = ("SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY") +KITTY_KEYBOARD_ENV_VARS = ( + "ALACRITTY_WINDOW_ID", + "KITTY_WINDOW_ID", + "WEZTERM_PANE", + "WT_SESSION", +) +KITTY_KEYBOARD_TERM_PROGRAMS = frozenset({ + "alacritty", + "ghostty", + "iterm.app", + "rio", + "warpterminal", + "wezterm", +}) +KITTY_KEYBOARD_TERM_PREFIXES = ("foot", "xterm-ghostty", "xterm-kitty") + + +def expects_kitty_keyboard() -> bool: + """Infer expected Kitty keyboard support without touching the terminal. + + Terminfo has no standardized capability for the Kitty keyboard protocol, + so this uses identifiers exported by terminal implementations known to + support it. Unknown terminals and terminal multiplexers fail closed. + + Implementations: https://sw.kovidgoyal.net/kitty/keyboard-protocol/ + """ + if os.environ.get("TMUX") or os.environ.get("ZELLIJ"): + return False + if any(os.environ.get(name) for name in KITTY_KEYBOARD_ENV_VARS): + return True + term_program = os.environ.get("TERM_PROGRAM", "").casefold() + if term_program in KITTY_KEYBOARD_TERM_PROGRAMS: + return True + term = os.environ.get("TERM", "").casefold() + return term.startswith(KITTY_KEYBOARD_TERM_PREFIXES) + + +def platform_clipboard() -> list[str] | None: + """Return the preferred clipboard command for the current platform.""" + override = os.environ.get("URSA_CLIPBOARD") + if override: + return shlex.split(override, posix=(os.name != "nt")) + if any(os.environ.get(name) for name in SSH_ENV_VARS): + return None + if sys.platform == "darwin": + return ["pbcopy"] if shutil.which("pbcopy") else None + if sys.platform.startswith("win"): + return ["clip"] if shutil.which("clip") else None + if os.environ.get("WAYLAND_DISPLAY") and shutil.which("wl-copy"): + return ["wl-copy"] + if os.environ.get("DISPLAY"): + if shutil.which("xclip"): + return ["xclip", "-selection", "clipboard"] + if shutil.which("xsel"): + return ["xsel", "--clipboard", "--input"] + return None + + +def copy_to_clipboard(text: str) -> bool: + """Copy text with the platform clipboard command, if one is available.""" + command = platform_clipboard() + if command is None: + return False + try: + subprocess.run( + command, + input=text, + text=True, + check=True, + timeout=2, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except (OSError, subprocess.SubprocessError): + return False + return True diff --git a/src/ursa/util/inference_providers.py b/src/ursa/util/inference_providers.py new file mode 100644 index 00000000..570d11e4 --- /dev/null +++ b/src/ursa/util/inference_providers.py @@ -0,0 +1,327 @@ +"""Inspect inference providers without importing their SDKs eagerly.""" + +from __future__ import annotations + +import importlib +from collections.abc import Callable, Iterable, Mapping +from dataclasses import dataclass, field +from datetime import datetime +from functools import lru_cache +from hashlib import sha256 +from importlib.util import find_spec +from typing import Any, Literal +from urllib.parse import urlparse + +from ursa.cli.config import InferenceProviderConfig, ModelConfig +from ursa.util.http import build_httpx_client + +ProviderConfig = InferenceProviderConfig | ModelConfig +ModelLister = Callable[[ProviderConfig], Iterable[Any]] + + +@dataclass(frozen=True) +class ProviderModel: + """A model advertised by an inference endpoint.""" + + name: str + model_provider: str | None = None + type: str | None = None + metadata: Mapping[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class _ProviderRequest: + fingerprint: tuple[Any, ...] + config: ProviderConfig = field(compare=False, hash=False, repr=False) + + +@lru_cache(maxsize=2) +def supported_model_providers( + model_type: Literal["chat", "embedding"] = "chat", +) -> tuple[str, ...]: + """Return built-in providers whose LangChain integration is installed.""" + if model_type == "embedding": + from langchain.embeddings.base import _BUILTIN_PROVIDERS + else: + from langchain.chat_models.base import _BUILTIN_PROVIDERS + + return tuple( + provider + for provider, ( + module, + _class_name, + _creator, + ) in _BUILTIN_PROVIDERS.items() + if find_spec(module.partition(".")[0]) is not None + ) + + +def sort_provider_models( + models: Iterable[ProviderModel], + model_type: Literal["chat", "embedding"], +) -> list[ProviderModel]: + """Rank models for a chat or embedding model picker.""" + + def recency(model: ProviderModel) -> float: + value = next( + ( + model.metadata.get(key) + for key in ("created", "created_at", "updated_at") + if model.metadata.get(key) is not None + ), + None, + ) + if isinstance(value, (int, float)) and not isinstance(value, bool): + return float(value) + if isinstance(value, str): + try: + return datetime.fromisoformat( + value.replace("Z", "+00:00") + ).timestamp() + except ValueError: + pass + return 0 + + def priority(model: ProviderModel) -> tuple[bool, bool, float, int, str]: + name = model.name.lower() + embedding = model.type == "embedding" or any( + marker in name for marker in ("embed", "text-embedding") + ) + deprioritized = any( + marker in name + for marker in ("whisper", "live", "realtime", "tts", "sora") + ) + wrong_type = embedding != (model_type == "embedding") + return deprioritized, wrong_type, -recency(model), len(name), name + + return sorted(models, key=priority) + + +def _client_type(module: str, name: str) -> type: + """Load a provider SDK only when its model-list endpoint is used.""" + return getattr(importlib.import_module(module), name) + + +def _secret(config: ProviderConfig, *, required: bool = True) -> str | None: + reference = config.api_key + value = reference.get_secret_value() if reference is not None else None + if required and not value: + raise ValueError("Inference provider API key is missing") + return value + + +def _sdk_kwargs(config: ProviderConfig) -> dict[str, Any]: + kwargs: dict[str, Any] = { + "api_key": _secret(config), + "http_client": build_httpx_client(verify=config.ssl_verify), + } + if config.base_url is not None: + kwargs["base_url"] = config.base_url + return kwargs + + +def _list_openai(config: ProviderConfig) -> Iterable[Any]: + kwargs = _sdk_kwargs(config) + with _client_type("openai", "OpenAI")(**kwargs) as client: + return list(client.models.list().data) + + +def _list_azure_openai(config: ProviderConfig) -> Iterable[Any]: + kwargs = _sdk_kwargs(config) + extra = config.model_extra or {} + kwargs.update({ + key: extra[key] + for key in ("azure_endpoint", "api_version") + if key in extra + }) + if "azure_endpoint" in kwargs: + kwargs.pop("base_url", None) + with _client_type("openai", "AzureOpenAI")(**kwargs) as client: + return list(client.models.list().data) + + +def _list_anthropic(config: ProviderConfig) -> Iterable[Any]: + with _client_type("anthropic", "Anthropic")( + **_sdk_kwargs(config) + ) as client: + return list(client.models.list().data) + + +def _list_google(config: ProviderConfig) -> Iterable[Any]: + with _client_type("google.genai", "Client")( + api_key=_secret(config) + ) as client: + return list(client.models.list()) + + +def _list_ollama(config: ProviderConfig) -> Iterable[Any]: + client = _client_type("ollama", "Client")( + host=config.base_url, verify=config.ssl_verify + ) + return client.list().models + + +_MODEL_LISTERS: dict[str, ModelLister] = { + "anthropic": _list_anthropic, + "azure_openai": _list_azure_openai, + "google_genai": _list_google, + "litellm": _list_openai, + "ollama": _list_ollama, + "openai": _list_openai, +} + + +def model_listing_provider(config: ProviderConfig) -> str: + """Return the API protocol used to list a provider's models.""" + model_provider = getattr(config, "model_provider", None) + if isinstance(model_provider, str) and model_provider: + return model_provider + parsed = urlparse(config.base_url or "") + endpoint = f"{parsed.hostname or ''}:{parsed.port or ''}".lower() + if "anthropic" in endpoint: + return "anthropic" + if "googleapis" in endpoint: + return "google_genai" + if parsed.port == 11434 or "ollama" in endpoint: + return "ollama" + if "azure" in endpoint: + return "azure_openai" + return "openai" + + +def _as_mapping(model: Any) -> dict[str, Any]: + if hasattr(model, "model_dump"): + return model.model_dump(mode="python") + if isinstance(model, dict): + return dict(model) + return { + key: getattr(model, key) + for key in ( + "id", + "name", + "model", + "owned_by", + "provider", + "model_provider", + "litellm_provider", + "type", + "model_type", + ) + if getattr(model, key, None) is not None + } + + +def _returned_model_provider( + raw: Mapping[str, Any], + name: str, + fallback: str, + supported: frozenset[str], +) -> str | None: + from langchain.chat_models.base import _attempt_infer_model_provider + + candidates = ( + raw.get("model_provider"), + raw.get("litellm_provider"), + ) + for candidate in candidates: + if not isinstance(candidate, str): + continue + if candidate in supported: + return candidate + inferred = _attempt_infer_model_provider(name.rsplit("/", 1)[-1]) + if inferred in supported: + return inferred + return fallback if fallback in supported else None + + +def _provider_model( + model: Any, fallback: str, supported: frozenset[str] +) -> ProviderModel: + raw = _as_mapping(model) + name = raw.get("id") or raw.get("name") or raw.get("model") + if not isinstance(name, str) or not name: + raise ValueError("Inference provider returned a model without a name") + model_type = raw.get("type") or raw.get("model_type") + metadata = { + key: value + for key, value in raw.items() + if key not in {"id", "name", "model", "type", "model_type"} + and value is not None + and isinstance(value, (str, int, float, bool, list, dict)) + } + return ProviderModel( + name=name, + model_provider=_returned_model_provider(raw, name, fallback, supported), + type=model_type if isinstance(model_type, str) else None, + metadata=metadata, + ) + + +@lru_cache(maxsize=16) +def _list_provider_models( + request: _ProviderRequest, +) -> tuple[ProviderModel, ...]: + provider = model_listing_provider(request.config) + lister = _MODEL_LISTERS.get(provider, _list_openai) + supported = frozenset(supported_model_providers()) + return tuple( + _provider_model(model, provider, supported) + for model in lister(request.config) + ) + + +def list_provider_models( + inference_provider: ProviderConfig, +) -> list[ProviderModel]: + """Return a cached model catalog advertised by an inference endpoint.""" + secret = _secret(inference_provider, required=False) + secret_fingerprint = ( + sha256(secret.encode()).digest() if secret is not None else None + ) + request = _ProviderRequest( + fingerprint=( + model_listing_provider(inference_provider), + inference_provider.base_url, + inference_provider.ssl_verify, + secret_fingerprint, + repr(inference_provider.model_extra), + ), + config=inference_provider, + ) + return list(_list_provider_models(request)) + + +def _matches_model(configured: str, advertised: str) -> bool: + return ( + configured == advertised or configured == advertised.rsplit("/", 1)[-1] + ) + + +def validate_model_provider( + inference_provider: ProviderConfig, + model_type: Literal["chat", "embedding"], +) -> None: + """Validate connectivity and model presence with a model-list request.""" + try: + models = list_provider_models(inference_provider) + if isinstance(inference_provider, ModelConfig) and not any( + _matches_model(inference_provider.model, model.name) + for model in models + ): + raise ValueError( + f"Model '{inference_provider.model}' is not available from " + "the inference provider" + ) + except Exception as exc: + provider = ( + getattr(inference_provider, "inference_provider", None) + or getattr(inference_provider, "model_provider", None) + or inference_provider.base_url + or "direct configuration" + ) + model = getattr(inference_provider, "model", "configured endpoint") + raise ValueError( + f"Unable to validate {model_type} model '{model}' with provider " + f"'{provider}': {exc}. Check the model name, provider endpoint, " + "and API credentials." + ) from exc diff --git a/src/ursa/util/mcp.py b/src/ursa/util/mcp.py index 1684ec82..0692e15e 100644 --- a/src/ursa/util/mcp.py +++ b/src/ursa/util/mcp.py @@ -1,6 +1,11 @@ +import asyncio +import os +import sys +from collections.abc import Mapping from datetime import timedelta -from typing import Annotated +from typing import Annotated, Any +from langchain_core.tools import BaseTool from langchain_mcp_adapters.client import MultiServerMCPClient from mcp import StdioServerParameters from mcp.client.session_group import ( @@ -13,6 +18,53 @@ from ursa.util.secrets import SecretTemplate +class UrsaMCPClient(MultiServerMCPClient): + """MCP client with tool provenance and quiet stdio subprocesses.""" + + def __init__(self, connections: dict[str, Any]) -> None: + wrapped: dict[str, Any] = {} + for name, connection in connections.items(): + connection = dict(connection) + if connection.get("transport") == "stdio": + command = str(connection["command"]) + args = list(connection.get("args") or []) + connection["command"] = sys.executable + connection["args"] = [ + "-m", + "ursa.util.mcp_stdio_proxy", + os.devnull, + command, + *args, + ] + wrapped[name] = connection + super().__init__(wrapped) + + +async def load_mcp_tools_with_sources( + client: MultiServerMCPClient, +) -> tuple[list[BaseTool], dict[str, str]]: + """Load MCP tools and retain their configured server names separately.""" + connections = getattr(client, "connections", None) + if not isinstance(connections, Mapping): + return await client.get_tools(), {} + + server_names = list(connections) + tools_by_server = await asyncio.gather( + *( + client.get_tools(server_name=server_name) + for server_name in server_names + ) + ) + tools: list[BaseTool] = [] + sources: dict[str, str] = {} + for server_name, server_tools in zip( + server_names, tools_by_server, strict=True + ): + tools.extend(server_tools) + sources.update({tool.name: server_name for tool in server_tools}) + return tools, sources + + def validate_server_parameters(config: dict): if not isinstance(config, dict): return config @@ -70,7 +122,7 @@ def transport(sp: ServerParameters) -> str: def start_mcp_client( server_configs: dict[str, ServerParameters | dict], -) -> MultiServerMCPClient: +) -> UrsaMCPClient: client_config = {} for server, config in server_configs.items(): if not isinstance(config, BaseModel): @@ -87,7 +139,7 @@ def start_mcp_client( if isinstance(config, (SseServerParameters, StreamableHttpParameters)): connection["httpx_client_factory"] = build_mcp_httpx_async_client client_config[server] = connection - return MultiServerMCPClient(client_config) + return UrsaMCPClient(client_config) def _resolve_header(value, server_name: str): diff --git a/src/ursa/util/mcp_stdio_proxy.py b/src/ursa/util/mcp_stdio_proxy.py new file mode 100644 index 00000000..a5e0c774 --- /dev/null +++ b/src/ursa/util/mcp_stdio_proxy.py @@ -0,0 +1,30 @@ +"""Launch a stdio MCP server with its stderr redirected to a file.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys + + +def main() -> None: + if len(sys.argv) < 3: + raise SystemExit( + "usage: python -m ursa.util.mcp_stdio_proxy FILE COMMAND [ARG ...]" + ) + path, command, *args = sys.argv[1:] + stderr_fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600) + try: + os.dup2(stderr_fd, 2) + finally: + os.close(stderr_fd) + if sys.platform == "win32": + executable = shutil.which(command) or command + result = subprocess.run([executable, *args], check=False) + raise SystemExit(result.returncode) + os.execvp(command, [command, *args]) + + +if __name__ == "__main__": + main() diff --git a/tests/agents/test_acquisition_agents.py b/tests/agents/test_acquisition_agents.py new file mode 100644 index 00000000..b7518cca --- /dev/null +++ b/tests/agents/test_acquisition_agents.py @@ -0,0 +1,371 @@ +import asyncio + +import pytest +from langchain_core.messages import AIMessage +from langgraph.types import Send + +import ursa.agents.acquisition_agents as acquisition_module +from ursa.agents.acquisition_agents import BaseAcquisitionAgent + + +class FakeAcquisitionAgent(BaseAcquisitionAgent): + def __init__(self, *args, hits=None, **kwargs): + self.hits = list(hits or []) + self.active = 0 + self.maximum_active = 0 + super().__init__(*args, **kwargs) + + def _search(self, query): + return self.hits + + async def _asearch(self, query): + return self.hits + + def _materialize(self, hit): + raise NotImplementedError + + async def _amaterialize(self, hit): + self.active += 1 + self.maximum_active = max(self.maximum_active, self.active) + try: + await asyncio.sleep(hit.get("delay", 0)) + if error := hit.get("error"): + raise RuntimeError(error) + return { + "id": hit["id"], + "title": hit.get("title", ""), + "url": hit.get("url", ""), + "full_text": hit.get("text", hit["id"]), + } + finally: + self.active -= 1 + + def _id(self, hit): + return hit["id"] + + def _citation(self, item): + return f"source:{item['id']}" + + +async def test_langgraph_fan_out_is_bounded_ordered_and_failure_isolated( + chat_model, tmp_path +): + hits = [ + {"id": "first", "delay": 0.03}, + { + "id": "bad", + "title": "Broken", + "url": "https://bad.test", + "delay": 0.01, + "error": "unavailable", + }, + {"id": "last"}, + ] + agent = FakeAcquisitionAgent( + llm=chat_model, + hits=hits, + summarize=False, + num_threads=2, + workspace=tmp_path, + ) + + result = await agent.ainvoke({"query": "ursa", "context": "compare"}) + + assert agent.maximum_active == 2 + assert [item["id"] for item in result["items"]] == [ + "first", + "bad", + "last", + ] + assert result["items"][1] == { + "id": "bad", + "title": "Broken", + "url": "https://bad.test", + "full_text": "[Error: unavailable]", + } + + +async def test_langgraph_max_concurrency_replaces_local_semaphores( + chat_model, tmp_path +): + agent = FakeAcquisitionAgent( + llm=chat_model, + hits=[{"id": "a"}, {"id": "b"}], + summarize=False, + num_threads=1, + workspace=tmp_path, + ) + + await agent.ainvoke({"query": "ursa", "context": "compare"}) + + assert agent.maximum_active == 1 + assert agent.build_config()["max_concurrency"] == 1 + assert agent.num_threads == 1 + + +async def test_cached_sources_are_fanned_out_filtered_and_sorted( + chat_model, tmp_path +): + database = tmp_path / "database" + database.mkdir() + (database / "b.html").write_text("second") + (database / "a.txt").write_text("first") + (database / "ignored.json").write_text("ignored") + agent = FakeAcquisitionAgent( + llm=chat_model, + summarize=False, + download=False, + database_path="database", + workspace=tmp_path, + ) + + result = await agent.ainvoke({"query": "unused", "context": "cached"}) + + assert [item["id"] for item in result["items"]] == ["a", "b"] + assert [item["full_text"] for item in result["items"]] == [ + "first", + "second", + ] + + +async def test_langgraph_map_reduce_preserves_source_order( + chat_model, tmp_path, monkeypatch +): + active = 0 + maximum_active = 0 + aggregate_input = None + + class FakeChain: + def __or__(self, _other): + return self + + async def ainvoke(self, values, config=None): + nonlocal active, maximum_active, aggregate_input + if "retrieved_content" in values: + active += 1 + maximum_active = max(maximum_active, active) + try: + text = values["retrieved_content"] + await asyncio.sleep( + {"alpha": 0.03, "beta": 0.01, "gamma": 0}[text] + ) + if text == "beta": + raise RuntimeError("model unavailable") + return f"summary:{text}" + finally: + active -= 1 + aggregate_input = values["Summaries"] + return "reduced answer" + + chain = FakeChain() + monkeypatch.setattr( + acquisition_module.ChatPromptTemplate, + "from_template", + lambda _template: chain, + ) + agent = FakeAcquisitionAgent( + llm=chat_model, + hits=[ + {"id": "a", "text": "alpha"}, + {"id": "b", "text": "beta"}, + {"id": "c", "text": "gamma"}, + ], + num_threads=2, + workspace=tmp_path, + ) + + result = await agent.ainvoke({"query": "ursa", "context": "compare"}) + + assert maximum_active == 2 + assert result["summaries"] == [ + "summary:alpha", + "[Error summarizing item b: model unavailable]", + "summary:gamma", + ] + expected_combined = ( + "\n\n[1] source:a\n\nSummary:\nsummary:alpha" + "\n\n----------------------------------------\n\n" + "[2] source:b\n\nSummary:\n" + "[Error summarizing item b: model unavailable]" + "\n\n----------------------------------------\n\n" + "[3] source:c\n\nSummary:\nsummary:gamma" + ) + assert aggregate_input == expected_combined + assert result["final_summary"] == "reduced answer" + + +def test_graph_uses_direct_nodes_and_send_router(chat_model, tmp_path): + agent = FakeAcquisitionAgent( + llm=chat_model, + hits=[{"id": "a"}, {"id": "b"}], + workspace=tmp_path, + ) + graph = agent.build_graph() + + assert set(graph.nodes) == { + "_search_query", + "_search_sources", + "_process_source", + "_reduce_sources", + } + routed = agent._fan_out_sources({ + "source_tasks": [ + {"index": 0, "context": "test", "hit": {"id": "a"}}, + {"index": 1, "context": "test", "hit": {"id": "b"}}, + ] + }) + assert isinstance(routed, list) + assert all(isinstance(command, Send) for command in routed) + assert [command.node for command in routed] == [ + "_process_source", + "_process_source", + ] + + +async def test_sync_source_adapter_does_not_block_event_loop( + chat_model, tmp_path +): + agent = FakeAcquisitionAgent(llm=chat_model, workspace=tmp_path) + started = asyncio.Event() + release = asyncio.Event() + main_loop = asyncio.get_running_loop() + + def search(_query): + main_loop.call_soon_threadsafe(started.set) + asyncio.run_coroutine_threadsafe(release.wait(), main_loop).result() + return [] + + agent._search = search + task = asyncio.create_task(BaseAcquisitionAgent._asearch(agent, "ursa")) + try: + await asyncio.wait_for(started.wait(), timeout=1) + assert not task.done() + finally: + release.set() + assert await asyncio.wait_for(task, timeout=1) == [] + + +async def test_existing_query_node_does_not_reemit_reducer_state( + chat_model, tmp_path +): + agent = FakeAcquisitionAgent(llm=chat_model, workspace=tmp_path) + state = { + "query": "ursa", + "context": "compare", + "processed_sources": [ + {"index": 0, "item": {"id": "a"}, "summary": None} + ], + } + + assert await agent._search_query(state) == {} + assert len(state["processed_sources"]) == 1 + + +async def test_generated_query_normalizes_structured_message_content( + chat_model, tmp_path +): + agent = FakeAcquisitionAgent(llm=chat_model, workspace=tmp_path) + + class StructuredModel: + async def ainvoke(self, _prompt): + return AIMessage( + content=[{"type": "text", "text": "ursa web search"}] + ) + + agent.llm = StructuredModel() + + assert await agent._search_query({"context": "research URSA"}) == { + "query": "ursa web search" + } + + +async def test_summarize_false_skips_rag(chat_model, tmp_path, monkeypatch): + agent = FakeAcquisitionAgent( + llm=chat_model, + hits=[{"id": "a"}], + summarize=False, + rag_embedding=object(), + workspace=tmp_path, + ) + + async def fail_if_called(_state): + raise AssertionError( + "RAG should not run when summarization is disabled" + ) + + monkeypatch.setattr(agent, "_arag_node", fail_if_called) + + result = await agent.ainvoke({"query": "ursa", "context": "compare"}) + + assert result["items"][0]["id"] == "a" + assert "final_summary" not in result + + +async def test_summary_cache_write_failure_does_not_abort_source( + chat_model, tmp_path, monkeypatch +): + class FakeChain: + def __or__(self, _other): + return self + + async def ainvoke(self, _values, config=None): + return "useful summary" + + monkeypatch.setattr( + acquisition_module.ChatPromptTemplate, + "from_template", + lambda _template: FakeChain(), + ) + agent = FakeAcquisitionAgent(llm=chat_model, workspace=tmp_path) + + def fail_write(*_args, **_kwargs): + raise OSError("read-only cache") + + monkeypatch.setattr(acquisition_module.Path, "write_text", fail_write) + + summary = await agent._summarize_source( + {"id": "a", "full_text": "content"}, 0, "compare" + ) + + assert summary == "useful summary" + + +def test_synchronous_stream_is_explicitly_unsupported(chat_model, tmp_path): + agent = FakeAcquisitionAgent( + llm=chat_model, + hits=[{"id": "a"}], + summarize=False, + workspace=tmp_path, + ) + + with pytest.raises(RuntimeError, match="do not support synchronous"): + list(agent.stream({"query": "ursa", "context": "compare"})) + + +async def test_aggregate_cache_write_failure_keeps_model_answer( + chat_model, tmp_path, monkeypatch +): + class FakeChain: + def __or__(self, _other): + return self + + async def ainvoke(self, _values, config=None): + return "reduced answer" + + monkeypatch.setattr( + acquisition_module.ChatPromptTemplate, + "from_template", + lambda _template: FakeChain(), + ) + agent = FakeAcquisitionAgent(llm=chat_model, workspace=tmp_path) + + def fail_write(*_args, **_kwargs): + raise OSError("read-only cache") + + monkeypatch.setattr(acquisition_module.Path, "write_text", fail_write) + + answer = await agent._aggregate_sources( + [{"id": "a"}], ["source summary"], "compare" + ) + + assert answer == "reduced answer" diff --git a/tests/agents/test_base/test_base.py b/tests/agents/test_base/test_base.py index 62303ac8..6794bdd5 100644 --- a/tests/agents/test_base/test_base.py +++ b/tests/agents/test_base/test_base.py @@ -10,13 +10,14 @@ from langchain_core.language_models.chat_models import BaseChatModel # LangChain core bits -from langchain_core.messages import AIMessage +from langchain_core.messages import AIMessage, HumanMessage from langchain_core.outputs import ChatGeneration, ChatResult from langgraph.graph.message import add_messages from langgraph.runtime import Runtime # Your project imports from ursa.agents.base import AgentContext, AgentWithTools, BaseAgent +from ursa.agents.chat_agent import BasicChatAgent from ursa.util import Checkpointer from ursa.util.checkpoint_retention import CheckpointPruneResult from ursa.util.events import DEFAULT_EVENT_LOGGING_HANDLER @@ -391,6 +392,41 @@ async def test_persistent_agent_ainvoke_uses_async_sqlite_resources( assert (agent.den / "graph_store.sqlite").is_file() +@pytest.mark.asyncio +async def test_named_agent_restores_state_in_a_new_instance( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + _use_temp_agent_groups(monkeypatch, tmp_path) + first = BasicChatAgent( + llm=TinyCountingModel(), + agent_name="resumed-agent", + enable_metrics=False, + ) + try: + await first.ainvoke(first.format_query("first turn")) + finally: + await first.aclose() + first.close() + + resumed = BasicChatAgent( + llm=TinyCountingModel(), + agent_name="resumed-agent", + enable_metrics=False, + ) + try: + result = await resumed.ainvoke(resumed.format_query("second turn")) + finally: + await resumed.aclose() + resumed.close() + + human_messages = [ + message.content + for message in result["messages"] + if isinstance(message, HumanMessage) + ] + assert human_messages == ["first turn", "second turn"] + + @pytest.mark.asyncio async def test_persistent_agent_supports_sync_and_async_invocation( tmp_path: Path, monkeypatch: pytest.MonkeyPatch diff --git a/tests/cli/_app_fakes.py b/tests/cli/_app_fakes.py new file mode 100644 index 00000000..9d276027 --- /dev/null +++ b/tests/cli/_app_fakes.py @@ -0,0 +1,88 @@ +from pathlib import Path +from types import SimpleNamespace + +from ursa.cli.config import ( + ChatModelConfig, + EmbModelConfig, + InferenceProviderConfig, +) +from ursa.util.events import DEFAULT_EVENT_NAME + + +class FakeAgent: + description = "A configured test agent." + config = {"mode": "test"} + + +class FakeHITL: + model = SimpleNamespace(model_name="test-model") + embedding = None + group = "default" + agent_name = None + agents = {"chat": FakeAgent(), "plan": FakeAgent()} + + def __init__(self, workspace: Path): + self.workspace = workspace + inference_providers = { + "openai": InferenceProviderConfig( + base_url="https://api.openai.com/v1", + api_key={"env": "OPENAI_API_KEY"}, + ) + } + self.config = SimpleNamespace( + mcp_servers={}, + inference_providers=inference_providers, + llm_model=ChatModelConfig( + model="openai:test-model", inference_provider="openai" + ).resolve_inference_provider(inference_providers), + emb_model=EmbModelConfig( + model="openai:test-embedding", inference_provider="openai" + ).resolve_inference_provider(inference_providers), + agent_name=None, + ) + self.calls = [] + self.model_changes = [] + self.inference_provider = "openai" + self.embedding_inference_provider = "openai" + self.closed = False + + async def run_agent(self, name, prompt, callbacks=None): + self.calls.append((name, prompt)) + return "Finished" + + async def aclose(self): + self.closed = True + + async def reconfigure_model(self, model_name, inference_provider): + self.model_changes.append((model_name, inference_provider)) + self.config.llm_model.model = model_name + self.inference_provider = inference_provider + + async def reconfigure_models( + self, + chat_config, + embedding_config, + ): + self.model_changes.append((chat_config, embedding_config)) + self.config.llm_model = chat_config.resolve_inference_provider( + self.config.inference_providers + ) + self.model = SimpleNamespace(model_name=self.config.llm_model.model) + self.config.emb_model = None + self.embedding = None + if embedding_config is not None: + self.config.emb_model = embedding_config.resolve_inference_provider( + self.config.inference_providers + ) + self.embedding = SimpleNamespace(model=self.config.emb_model.model) + self.inference_provider = chat_config.inference_provider + self.embedding_inference_provider = ( + embedding_config.inference_provider if embedding_config else None + ) + + +async def emit_event(handler, payload=None, **details): + await handler.on_custom_event( + DEFAULT_EVENT_NAME, + details if payload is None else payload, + ) diff --git a/tests/cli/test_cli_parser.py b/tests/cli/test_cli_parser.py index 4a497a11..406fc702 100644 --- a/tests/cli/test_cli_parser.py +++ b/tests/cli/test_cli_parser.py @@ -7,7 +7,6 @@ import pytest import yaml from jsonargparse import Namespace -from openai import OpenAIError from pydantic import ValidationError from ursa.cli import ( @@ -45,18 +44,18 @@ def _stub_mcp_server(monkeypatch): hitl = MagicMock() hitl.as_mcp_server.return_value = mcp hitl_class = MagicMock(return_value=hitl) - monkeypatch.setattr("ursa.cli.hitl.HITL", hitl_class) + monkeypatch.setattr("ursa.cli.runtime.HITL", hitl_class) monkeypatch.setattr("ursa.cli.inject_truststore_into_ssl", lambda: None) return hitl, mcp -def _stub_cli_repl(monkeypatch): +def _stub_textual(monkeypatch): hitl_class = MagicMock() - repl_class = MagicMock() - monkeypatch.setattr("ursa.cli.hitl.HITL", hitl_class) - monkeypatch.setattr("ursa.cli.hitl.UrsaRepl", repl_class) + run_textual = MagicMock() + monkeypatch.setattr("ursa.cli.runtime.HITL", hitl_class) + monkeypatch.setattr("ursa.cli.tui.app.run_textual", run_textual) monkeypatch.setattr("ursa.cli.inject_truststore_into_ssl", lambda: None) - return hitl_class, repl_class + return hitl_class, run_textual def _parse_config_args(parser, args): @@ -75,7 +74,7 @@ def _resolve_args(parser, args): def test_cli_warns_about_legacy_unnamed_checkpoint( monkeypatch, tmp_path, capsys ): - _stub_cli_repl(monkeypatch) + _stub_textual(monkeypatch) checkpoint = tmp_path / "db" / "checkpointer.db" checkpoint.parent.mkdir() checkpoint.touch() @@ -95,7 +94,7 @@ def test_cli_warns_about_legacy_unnamed_checkpoint( def test_cli_does_not_warn_about_legacy_checkpoint_with_name( monkeypatch, tmp_path, capsys ): - _stub_cli_repl(monkeypatch) + _stub_textual(monkeypatch) checkpoint = tmp_path / "db" / "checkpointer.db" checkpoint.parent.mkdir() checkpoint.touch() @@ -108,41 +107,79 @@ def test_cli_does_not_warn_about_legacy_checkpoint_with_name( def test_cli_does_not_warn_without_legacy_checkpoint( monkeypatch, tmp_path, capsys ): - _stub_cli_repl(monkeypatch) + _stub_textual(monkeypatch) main(["--workspace", str(tmp_path)]) assert capsys.readouterr().err == "" -@pytest.mark.parametrize("args", [[], ["mcp-server"]]) -def test_cli_reports_model_initialization_error_without_traceback( +def test_exec_uses_textual_one_shot_renderer(monkeypatch): + hitl = MagicMock() + run_once = MagicMock() + monkeypatch.setattr("ursa.cli.runtime.HITL", MagicMock(return_value=hitl)) + monkeypatch.setattr("ursa.cli.tui.app.run_textual_once", run_once) + monkeypatch.setattr("ursa.cli.inject_truststore_into_ssl", lambda: None) + + main(["exec", "#plan inspect this"]) + + run_once.assert_called_once_with(hitl, "#plan inspect this") + + +@pytest.mark.parametrize("mode", ["interactive", "exec"]) +def test_named_agent_reaches_textual_runtime(monkeypatch, mode): + hitl_class = MagicMock() + run_textual = MagicMock() + run_once = MagicMock() + monkeypatch.setattr("ursa.cli.runtime.HITL", hitl_class) + monkeypatch.setattr("ursa.cli.tui.app.run_textual", run_textual) + monkeypatch.setattr("ursa.cli.tui.app.run_textual_once", run_once) + monkeypatch.setattr("ursa.cli.inject_truststore_into_ssl", lambda: None) + args = ["--name", "lab-assistant"] + if mode == "exec": + args.extend(["exec", "#plan inspect this"]) + + main(args) + + config = hitl_class.call_args.args[0] + assert config.agent_name == "lab-assistant" + if mode == "interactive": + run_textual.assert_called_once_with(hitl_class.return_value) + else: + run_once.assert_called_once_with( + hitl_class.return_value, "#plan inspect this" + ) + + +@pytest.mark.parametrize("args", [[], ["mcp-server"], ["exec", "hello"]]) +def test_cli_reports_runtime_initialization_error_without_traceback( monkeypatch, capsys, args ): - error = OpenAIError( - "The api_key client option must be set by setting the " - "OPENAI_API_KEY environment variable" + monkeypatch.setattr( + "ursa.cli.runtime.HITL", + MagicMock(side_effect=ValueError("API credentials are invalid")), ) - monkeypatch.setattr("ursa.cli.hitl.HITL", MagicMock(side_effect=error)) monkeypatch.setattr("ursa.cli.inject_truststore_into_ssl", lambda: None) with pytest.raises(SystemExit, match="2"): main(args) stderr = capsys.readouterr().err - assert stderr.startswith("Error: unable to initialize the language model.") - assert "OPENAI_API_KEY" in stderr + assert stderr == "Error: API credentials are invalid\n" assert "Traceback" not in stderr -def test_exec_runs_prompt_with_repl(monkeypatch): - hitl_class, repl_class = _stub_cli_repl(monkeypatch) +def test_exec_runs_prompt_with_textual_runtime(monkeypatch): + hitl_class = MagicMock() + run_once = MagicMock() + monkeypatch.setattr("ursa.cli.runtime.HITL", hitl_class) + monkeypatch.setattr("ursa.cli.tui.app.run_textual_once", run_once) + monkeypatch.setattr("ursa.cli.inject_truststore_into_ssl", lambda: None) main(["exec", "summarize this"]) hitl = hitl_class.return_value - repl_class.assert_called_once_with(hitl) - repl_class.return_value.run_prompt.assert_called_once_with("summarize this") + run_once.assert_called_once_with(hitl, "summarize this") @pytest.mark.parametrize( @@ -156,7 +193,7 @@ def test_exec_runs_prompt_with_repl(monkeypatch): def test_legacy_ursa_name_is_supported_with_deprecation_warning( monkeypatch, modern_env, cli_args, expected ): - hitl_class, _ = _stub_cli_repl(monkeypatch) + hitl_class, _ = _stub_textual(monkeypatch) monkeypatch.setenv("URSA_NAME", "legacy-agent") if modern_env is not None: monkeypatch.setenv("URSA_AGENT_NAME", modern_env) @@ -249,7 +286,7 @@ def test_mcp_server_config_flag_sets_hosted_llm(monkeypatch, tmp_path): hitl_class = MagicMock() hitl_class.return_value = MagicMock() hitl_class.return_value.as_mcp_server.return_value = MagicMock() - monkeypatch.setattr("ursa.cli.hitl.HITL", hitl_class) + monkeypatch.setattr("ursa.cli.runtime.HITL", hitl_class) monkeypatch.setattr("ursa.cli.inject_truststore_into_ssl", lambda: None) config_file = tmp_path / "mcp.yaml" @@ -279,7 +316,7 @@ def test_mcp_server_config_flag_parses_alongside_transport( hitl_class.return_value = MagicMock() mcp = MagicMock() hitl_class.return_value.as_mcp_server.return_value = mcp - monkeypatch.setattr("ursa.cli.hitl.HITL", hitl_class) + monkeypatch.setattr("ursa.cli.runtime.HITL", hitl_class) monkeypatch.setattr("ursa.cli.inject_truststore_into_ssl", lambda: None) config_file = tmp_path / "mcp.yaml" diff --git a/tests/cli/test_hitl.py b/tests/cli/test_hitl.py deleted file mode 100644 index d3df8c63..00000000 --- a/tests/cli/test_hitl.py +++ /dev/null @@ -1,932 +0,0 @@ -# ruff: noqa: TID251 - -import asyncio -import io -import logging -import re -from pathlib import Path -from random import random -from sys import executable -from unittest.mock import MagicMock, patch - -import pytest -from fastmcp.client import Client -from mcp import StdioServerParameters -from pydantic import ValidationError -from rich.console import Console as RealConsole - -from ursa import agents -from ursa.agents.base import URSA_VERSION, AgentWithTools -from ursa.cli.callbacks import HITLLogEventHandler -from ursa.cli.config import EmbModelConfig, UrsaConfig, resolve_ursa_config -from ursa.cli.hitl import HITL, AgentHITL, UrsaRepl, ursa_banner -from ursa.util.events import DEFAULT_EVENT_NAME -from ursa.util.has_optional_dep_group import has_optional_dep_group -from ursa.util.rendering import event_artifact - -LOGGER = logging.getLogger(__name__) - - -@pytest.fixture(autouse=True) -def stub_duckduckgo(monkeypatch): - class DummyDDGS: - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def text(self, *args, **kwargs): - yield { - "href": "https://example.com", - "title": "Example Result", - "body": "Example summary", - } - - monkeypatch.setattr( - "ursa.agents.acquisition_agents.DDGS", - lambda: DummyDDGS(), - raising=False, - ) - monkeypatch.setattr( - "ursa.agents.hypothesizer_agent.DDGS", - lambda: DummyDDGS(), - raising=False, - ) - - -@pytest.fixture(scope="function") -def ursa_config(tmpdir, chat_model, embedding_model): - config = UrsaConfig( - workspace=Path(tmpdir), - llm_model=chat_model._testing_only_kwargs, - emb_model=embedding_model._testing_only_kwargs, - ) - print("ursa config:", config) # Displayed on test failure - return config - - -async def test_default_config_smoke(ursa_config): - hitl = HITL(ursa_config) - assert hitl is not None - assert set(hitl.agents.keys()) >= {"chat", "plan", "execute"} - out = await hitl.run_agent("chat", "Hello! What is your name?") - print("chat out:", out) - assert len(out) > 0 - - -DOCS_ROOT = Path(__file__).resolve().parents[2] -DOC_EXAMPLE_CONFIG = DOCS_ROOT / "configs" / "example.yaml" - - -def test_example_config_smoke(monkeypatch): - monkeypatch.setenv("OPENAI_API_KEY", "test-api-key") - assert DOC_EXAMPLE_CONFIG.is_file() - ursa_config = resolve_ursa_config(UrsaConfig.from_file(DOC_EXAMPLE_CONFIG)) - hitl = HITL(ursa_config) - repl = UrsaRepl(hitl) - for name in hitl.agents: - assert hasattr(repl, f"do_{name}") - - -def test_has_all_agent_do_methods(ursa_config): - hitl = HITL(ursa_config) - repl = UrsaRepl(hitl) - for name in hitl.agents: - assert hasattr(repl, f"do_{name}") - - -@pytest.mark.parametrize( - ("agent_name", "agent_class_name", "deps"), - [ - ("chat", "ChatAgent", None), - ("arxiv", "ArxivAgent", None), - ("dsi", "DSIAgent", "dsi"), - ("execute", "ExecutionAgent", None), - ("deep_review", "DeepReviewAgent", None), - ("hypothesize", "HypothesizerAgent", None), - ("plan", "PlanningAgent", None), - ("prompt", "PromptingAgent", None), - ("web", "WebSearchAgent", None), - ("lammps", "LammpsAgent", "lammps"), - ], -) -def test_hitl_agent_registry_respects_optional_deps( - ursa_config, agent_name, agent_class_name, deps -): - hitl = HITL(ursa_config) - - if deps is not None and not has_optional_dep_group(deps): - assert agent_name not in hitl.agents - return - - assert agent_name in hitl.agents - assert hitl.agents[agent_name].agent_class is getattr( - agents, agent_class_name - ) - - -def test_banner_shows_version(): - # Issue 298: the running version rides next to the ascii logo. - assert f"v{URSA_VERSION}" in ursa_banner - - -def test_banner_panel_shows_workspace(ursa_config): - hitl = HITL(ursa_config) - repl = UrsaRepl(hitl, stdout=io.StringIO()) - - console = RealConsole(file=io.StringIO(), width=200) - console.print(repl.llm_model_panel) - - assert ( - str(Path(ursa_config.workspace).absolute()) in console.file.getvalue() - ) - - -def _repl_with_stub_agent(ursa_config, monkeypatch, reply="agent says hi"): - hitl = HITL(ursa_config) - - async def fake_run(name, prompt, callbacks=None): - return reply - - monkeypatch.setattr(hitl, "run_agent", fake_run) - return UrsaRepl(hitl, stdout=io.StringIO()) - - -def test_agent_invocation_uses_bear_inline_prompt(ursa_config, monkeypatch): - # Issue 264: the user's turn is marked inline in the prompt, not echoed. - repl = _repl_with_stub_agent(ursa_config, monkeypatch) - - repl.run_agent("chat", "what does this bug mean?") - - out = repl.stdout.getvalue() - assert "agent says hi" in out - assert "what does this bug mean?" not in out - assert "chat>" not in out - - -def test_user_turn_is_not_echoed_as_markup(ursa_config, monkeypatch): - # User text is no longer echoed, so rich markup must not appear either. - repl = _repl_with_stub_agent(ursa_config, monkeypatch) - - repl.run_agent("chat", "explain [red]this[/red] tag") - - out = repl.stdout.getvalue() - assert "[red]this[/red]" not in out - - -def test_bare_text_default_route_does_not_echo_user_turn(ursa_config, monkeypatch): - # Bare text goes to the chat agent through default(); the request is not echoed. - repl = _repl_with_stub_agent(ursa_config, monkeypatch) - - repl.default("hello there") - - out = repl.stdout.getvalue() - assert "agent says hi" in out - assert "hello there" not in out - assert "chat>" not in out - - -def test_onecmd_dispatch_route_does_not_echo_user_turn(ursa_config, monkeypatch): - # The do_ dispatch through onecmd also avoids echoing the request. - repl = _repl_with_stub_agent(ursa_config, monkeypatch) - - repl.onecmd("chat summarize the log") - - out = repl.stdout.getvalue() - assert "agent says hi" in out - assert "summarize the log" not in out - assert "chat>" not in out - - -def test_turns_end_with_a_dim_rule(ursa_config, monkeypatch): - # The blank turn separator became a full-width dim rule, chunking - # scrollback into visual blocks (issue 264). - repl = _repl_with_stub_agent(ursa_config, monkeypatch) - - repl.run_prompt("chat hello there") - - out = repl.stdout.getvalue() - assert "────" in out, "no rule separating turns" - assert out.index("agent says hi") < out.index("────"), ( - "the rule must close the turn after the agent output" - ) - - -async def test_agents_use_configured_workspace(ursa_config, tmp_path): - workspace = tmp_path / "custom-workspace" - ursa_config.workspace = workspace - - hitl = HITL(ursa_config) - agent = await hitl.get_agent("chat") - assert agent._agent is not None - assert agent._agent.workspace == workspace - - -@pytest.mark.asyncio -async def test_unnamed_cli_agent_does_not_create_checkpointer( - tmp_path, monkeypatch -): - _stub_hitl_dependencies(monkeypatch) - workspace = tmp_path / "ephemeral-workspace" - hitl = HITL(UrsaConfig(workspace=workspace)) - - async def unexpected_checkpointer(_checkpoint_path): - pytest.fail("Unnamed CLI sessions must not create a checkpointer") - - monkeypatch.setattr(hitl, "_get_checkpointer", unexpected_checkpointer) - - agent = await hitl.get_agent("chat") - - assert agent._agent is not None - assert agent._agent.checkpointer is None - assert not (workspace / "db" / "checkpointer.db").exists() - - -@pytest.mark.asyncio -async def test_named_cli_agent_still_gets_async_checkpointer( - tmp_path, monkeypatch -): - _stub_hitl_dependencies(monkeypatch) - workspace = tmp_path / "workspace" - hitl = HITL(UrsaConfig(workspace=workspace, agent_name="persistent-agent")) - persistent_den = tmp_path / "persistent-agent-den" - expected_checkpointer = object() - requested_paths = [] - - class DummyPersistentAgent: - def __init__(self, **_kwargs): - self.den = persistent_den - self.checkpointer = None - - async def fake_get_checkpointer(checkpoint_path): - requested_paths.append(checkpoint_path) - return expected_checkpointer - - hitl.agents["chat"] = AgentHITL(agent_class=DummyPersistentAgent) - monkeypatch.setattr(hitl, "_get_checkpointer", fake_get_checkpointer) - - agent = await hitl.get_agent("chat") - - assert agent._agent is not None - assert requested_paths == [persistent_den] - assert agent._agent.checkpointer is expected_checkpointer - - -def _stub_hitl_dependencies(monkeypatch): - fake_llm = MagicMock(name="llm") - fake_embedding = MagicMock(name="embedding") - monkeypatch.setattr("ursa.cli.config.init_chat_model", lambda **_: fake_llm) - monkeypatch.setattr( - "ursa.cli.config.init_embeddings", lambda **_: fake_embedding - ) - monkeypatch.setattr("ursa.cli.hitl.start_mcp_client", lambda servers: None) - return fake_llm, fake_embedding - - -@pytest.mark.parametrize( - "agent_name", - [ - "chat", - "arxiv", - "execute", - "hypothesize", - "plan", - "web", - ] - + (["dsi"] if has_optional_dep_group("dsi") else []), -) -async def test_agents_apply_agent_config_overrides( - agent_name, tmp_path, monkeypatch -): - _stub_hitl_dependencies(monkeypatch) - - config = UrsaConfig( - workspace=tmp_path / "global-workspace", - emb_model=EmbModelConfig(model="fake-embedding"), - ) - - overrides = {} - overrides[agent_name] = { - "workspace": tmp_path / f"{agent_name}-workspace", - "enable_metrics": random() > 0.5, - } - - config.agent_config = overrides - - hitl = HITL(config) - - agent = await hitl.get_agent(agent_name) - override = overrides[agent_name] - assert agent._agent is not None - assert agent._agent.workspace == override["workspace"] - assert agent._agent.telemetry.enable == override["enable_metrics"] - - -@pytest.mark.asyncio -async def test_thread_id_propagates_from_config(tmp_path, monkeypatch): - _stub_hitl_dependencies(monkeypatch) - config = UrsaConfig( - workspace=tmp_path / "global-workspace", - thread_id="custom-thread", - emb_model=EmbModelConfig(model="fake-embedding"), - ) - - hitl = HITL(config) - assert hitl.thread_id == "custom-thread" - - agent = await hitl.get_agent("chat") - assert agent._agent is not None - assert agent._agent.thread_id == "custom-thread" - - -@pytest.mark.asyncio -async def test_hitl_run_agent_forwards_callbacks(tmp_path, monkeypatch): - _stub_hitl_dependencies(monkeypatch) - config = UrsaConfig( - workspace=tmp_path / "global-workspace", - emb_model=EmbModelConfig(model="fake-embedding"), - ) - hitl = HITL(config) - captured = {} - previous_agent = object() - current_agent = object() - - class DummyAgent: - _agent = current_agent - - async def __call__( - self, - prompt: str, - last_agent_result: str | None = None, - last_agent=None, - callbacks=None, - ) -> str: - captured["prompt"] = prompt - captured["last_agent_result"] = last_agent_result - captured["last_agent"] = last_agent - captured["callbacks"] = callbacks - return "agent result" - - async def fake_get_agent(name: str): - assert name == "chat" - return DummyAgent() - - hitl.last_agent_result = "previous result" - hitl.last_agent = previous_agent - monkeypatch.setattr(hitl, "get_agent", fake_get_agent) - - callbacks = ["callback-1"] - result = await hitl.run_agent("chat", "hello", callbacks=callbacks) - - assert result == "agent result" - assert captured == { - "prompt": "hello", - "last_agent_result": "previous result", - "last_agent": previous_agent, - "callbacks": callbacks, - } - assert hitl.last_agent_result == "agent result" - assert hitl.last_agent is current_agent - - -@pytest.mark.asyncio -async def test_agent_hitl_passes_extra_callbacks_only(): - captured = {} - custom_callback = object() - - class DummyAgent: - telemetry = type("Telemetry", (), {"callbacks": ["telemetry"]})() - - def format_query(self, prompt: str, state=None): - captured["prompt"] = prompt - captured["state"] = state - return {"messages": [prompt]} - - async def ainvoke(self, query, config=None): - captured["query"] = query - captured["config"] = config - return {"messages": ["done"]} - - def format_result(self, result): - return "done" - - wrapper = AgentHITL(agent_class=object) - wrapper._agent = DummyAgent() - - result = await wrapper("hello", callbacks=[custom_callback]) - - assert result == "done" - assert captured["prompt"] == "hello" - assert captured["state"] is None - assert captured["query"] == {"messages": ["hello"]} - assert captured["config"] == {"callbacks": [custom_callback]} - - -def test_hitl_log_event_handler_renders_events(tmp_path): - output = io.StringIO() - console = RealConsole( - file=output, - force_terminal=False, - force_interactive=False, - color_system=None, - width=80, - ) - handler = HITLLogEventHandler(console=console, workspace=tmp_path) - - asyncio.run( - handler.on_custom_event( - DEFAULT_EVENT_NAME, - { - "agent": "PlanningAgent", - "stage": "reflect_result", - "message": "Plan needs another pass", - "approved": False, - "reason": "Need one more concrete step.", - }, - run_id="agent-run", - ) - ) - asyncio.run( - handler.on_custom_event( - DEFAULT_EVENT_NAME, - { - "tool": "edit_code", - "stage": "edit", - "phase": "end", - "message": "File updated", - "artifact": event_artifact( - "--- app.py\n+++ app.py\n-old\n+new", - "text/x-diff", - metadata={ - "title": "Edit diff", - "path": "repo/app.py", - }, - ), - }, - run_id="edit-tool-artifact-run", - ) - ) - asyncio.run( - handler.on_tool_start( - {"name": "run_command"}, - '{"query":"uname -s"}', - run_id="tool-run", - inputs={"query": "uname -s"}, - ) - ) - asyncio.run( - handler.on_custom_event( - DEFAULT_EVENT_NAME, - { - "tool": "run_command", - "stage": "execute", - "phase": "end", - "message": "Command finished", - "artifacts": [ - event_artifact( - "Darwin", - "text/plain", - metadata={"title": "stdout"}, - ), - event_artifact( - "warning", - "text/plain", - metadata={"title": "stderr"}, - ), - ], - }, - run_id="tool-run", - ) - ) - asyncio.run( - handler.on_tool_end( - "STDOUT:\nDarwin\nSTDERR:\n", - run_id="tool-run", - ) - ) - asyncio.run( - handler.on_tool_start( - {"name": "write_code_with_repo"}, - '{"filename":"repo/app.py"}', - run_id="write-tool-run", - inputs={"filename": "repo/app.py"}, - ) - ) - asyncio.run( - handler.on_tool_end( - "File repo/app.py written successfully.", - run_id="write-tool-run", - ) - ) - asyncio.run( - handler.on_custom_event( - DEFAULT_EVENT_NAME, - { - "tool": "edit_code", - "stage": "edit", - "phase": "start", - "message": "Editing file", - "path": str(tmp_path / "repo" / "app.py"), - }, - run_id="edit-tool-run", - ) - ) - asyncio.run( - handler.on_custom_event( - DEFAULT_EVENT_NAME, - { - "tool": "edit_code", - "stage": "edit", - "message": "No changes made", - "filename": "repo/app.py", - "reason": "'old_code' not found in file.", - }, - run_id="edit-tool-noop-run", - ) - ) - asyncio.run( - handler.on_custom_event( - DEFAULT_EVENT_NAME, - { - "tool": "run_web_search", - "stage": "search", - "message": "Searching Web", - "query": "ursa events", - }, - run_id="search-tool-run", - ) - ) - asyncio.run( - handler.on_custom_event( - DEFAULT_EVENT_NAME, - { - "tool": "run_web_search", - "stage": "search_result", - "message": "Web search complete", - "query": "ursa events", - "result_chars": 42, - }, - run_id="search-tool-result-run", - ) - ) - asyncio.run( - handler.on_custom_event( - DEFAULT_EVENT_NAME, - { - "agent": "LammpsAgent", - "stage": "choose_potential", - "phase": "end", - "message": "Potential chosen", - "chosen_index": 2, - "potential_id": "pot-2", - "rationale": "Best fit for the requested elements.", - }, - run_id="lammps-choice-run", - ) - ) - asyncio.run( - handler.on_custom_event( - DEFAULT_EVENT_NAME, - { - "agent": "LammpsAgent", - "stage": "author_input", - "phase": "end", - "message": "LAMMPS input authored", - "preview": "units metal\nrun 100", - "language": "bash", - "path": str(tmp_path / "in.lammps"), - }, - run_id="lammps-author-run", - ) - ) - asyncio.run( - handler.on_custom_event( - DEFAULT_EVENT_NAME, - { - "agent": "LammpsAgent", - "stage": "fix_input", - "phase": "end", - "message": "LAMMPS input rewritten", - "old_code": "run 100", - "new_code": "run 200", - "path": str(tmp_path / "in.lammps"), - }, - run_id="lammps-fix-run", - ) - ) - asyncio.run( - handler.on_custom_event( - DEFAULT_EVENT_NAME, - { - "agent": "LammpsAgent", - "stage": "run", - "phase": "error", - "message": "LAMMPS run failed", - "returncode": 1, - "error_output": "ERROR: Invalid pair style", - }, - run_id="lammps-failed-run", - ) - ) - - rendered = output.getvalue() - - assert "Edit diff" in rendered - assert "-old" in rendered - assert "+new" in rendered - - repo_app_string = str( - Path("repo") / "app.py" - ) # Rendering OS specific path string - - assert "Plan" in rendered - assert "Plan needs another pass" in rendered - assert "Need one more concrete step." in rendered - assert "Running command: uname -s" in rendered - assert "Command finished: uname -s" in rendered - assert "stdout" in rendered - assert "stderr" in rendered - assert "warning" in rendered - assert any( - "stdout" in line and "stderr" in line for line in rendered.splitlines() - ) - assert "Darwin" in rendered - assert f"Writing file: {repo_app_string}" in rendered - assert f"File written: {repo_app_string}" in rendered - assert f"Editing file: {repo_app_string}" in rendered - assert f"No changes made: {repo_app_string}" in rendered - assert "'old_code' not found in file." in rendered - assert "Searching Web: ursa events" in rendered - assert "Web search complete: ursa events" in rendered - assert "42 chars" in rendered - assert "LAMMPS" in rendered - assert "Chosen Potential" in rendered - assert "pot-2" in rendered - assert "Best fit for the requested elements." in rendered - assert "LAMMPS input authored" in rendered - assert "units metal" in rendered - assert "LAMMPS input diff" in rendered - assert "run 100" in rendered - assert "run 200" in rendered - assert "Run error/output" in rendered - assert "ERROR: Invalid pair style" in rendered - - -def test_hitl_log_event_handler_renders_named_agent_tool_artifacts(tmp_path): - output = io.StringIO() - console = RealConsole( - file=output, - force_terminal=False, - force_interactive=False, - color_system=None, - width=80, - ) - handler = HITLLogEventHandler(console=console, workspace=tmp_path) - - async def emit_events() -> None: - await handler.on_custom_event( - DEFAULT_EVENT_NAME, - { - "agent": "dummy_bot_3000", - "tool": "write_code", - "stage": "write", - "phase": "end", - "message": "File written", - "filename": "first_10_integers.py", - "artifact": event_artifact( - "for i in range(1, 11):\n print(i)\n", - "text/x-python", - metadata={"title": "File written"}, - ), - }, - run_id="named-write-tool-run", - ) - await handler.on_custom_event( - DEFAULT_EVENT_NAME, - { - "agent": "dummy_bot_3000", - "tool": "run_command", - "stage": "execute", - "phase": "end", - "message": "Command finished", - "query": "python first_10_integers.py", - "artifacts": [ - event_artifact( - "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n", - "text/plain", - metadata={"title": "stdout"}, - ) - ], - }, - run_id="named-command-tool-run", - ) - - asyncio.run(emit_events()) - - rendered = output.getvalue() - assert "File written" in rendered - assert "for i in range(1, 11):" in rendered - assert "print(i)" in rendered - assert "stdout" in rendered - assert "1" in rendered - assert "10" in rendered - - -def test_repl_run_agent_registers_progress_handler(tmp_path, monkeypatch): - _stub_hitl_dependencies(monkeypatch) - config = UrsaConfig( - workspace=tmp_path / "global-workspace", - emb_model=EmbModelConfig(model="fake-embedding"), - ) - hitl = HITL(config) - shell = UrsaRepl(hitl, stdout=io.StringIO()) - captured = {} - - async def fake_run_agent(name: str, prompt: str, callbacks=None) -> str: - captured["name"] = name - captured["prompt"] = prompt - captured["callbacks"] = callbacks - return "done" - - monkeypatch.setattr(hitl, "run_agent", fake_run_agent) - monkeypatch.setattr( - shell.ursa_loop, "submit", lambda coro: asyncio.run(coro) - ) - monkeypatch.setattr(shell, "show", lambda *args, **kwargs: None) - - shell.run_agent("chat", "hello") - - assert captured["name"] == "chat" - assert captured["prompt"] == "hello" - assert len(captured["callbacks"]) == 1 - assert isinstance(captured["callbacks"][0], HITLLogEventHandler) - - -def test_agent_config_unknown_agent_raises(tmp_path, monkeypatch): - _stub_hitl_dependencies(monkeypatch) - config = UrsaConfig( - workspace=tmp_path / "global-workspace", - emb_model=EmbModelConfig(model="fake-embedding"), - ) - config.agent_config = { - "ghost": {"workspace": tmp_path / "ghost-workspace"}, - } - - with pytest.raises(AssertionError, match="Unknown agent ghost"): - HITL(config) - - -def test_agent_config_none_value_errors(tmp_path, monkeypatch): - _stub_hitl_dependencies(monkeypatch) - config = UrsaConfig( - workspace=tmp_path / "global-workspace", - emb_model=EmbModelConfig(model="fake-embedding"), - ) - with pytest.raises(ValidationError): - config.agent_config = {"chat": None} - - -@pytest.mark.asyncio -async def test_agent_config_unknown_option_raises(tmp_path, monkeypatch): - _stub_hitl_dependencies(monkeypatch) - config = UrsaConfig( - workspace=tmp_path / "global-workspace", - emb_model=EmbModelConfig(model="fake-embedding"), - ) - config.agent_config = {"chat": {"nonexistent_option": True}} - - hitl = HITL(config) - - with pytest.raises(TypeError, match="nonexistent_option"): - await hitl.get_agent("chat") - - -def check_script( - ursa_config: UrsaConfig, - input_expected: list[tuple[str, str | int | re.Pattern | None]], -): - stdout = io.StringIO() - stdout_pos = 0 - - def console_factory(*args, **kwargs): - kwargs["record"] = True - kwargs["force_terminal"] = False - kwargs["force_interactive"] = False - return RealConsole(*args, **kwargs) - - # Patch the Console constructor so we can snoop - with patch("ursa.cli.hitl.Console", new=console_factory): - shell = UrsaRepl(HITL(ursa_config), stdout=stdout) - - # Feed the REPL with the script and check the output matches - # expectations - trace = [] - for input, ref in input_expected: - LOGGER.info("input: %s", input) - shell.onecmd(input) - console_output = shell.console.export_text() - stdout_value = stdout.getvalue() - stdout_delta = stdout_value[stdout_pos:] - stdout_pos = len(stdout_value) - output = stdout_delta or console_output - LOGGER.info("output: %s", output) - match ref: - case str(): - assert output == ref - case int(): - assert len(output.strip()) >= ref - case re.Pattern(): - assert ref.search(output) is not None - case None: - pass - case _: - assert False, f"Unknown reference type: {ref}" - - trace.append({"input": input, "output": output}) - - return trace - - -def test_repl_smoke(ursa_config): - def docstr_header(cls) -> str: - docs = cls.__doc__ - assert isinstance(docs, str) - return docs.split("\n", maxsplit=1)[0] - - trace = check_script( - ursa_config, - [ - ("What is your name?", None), - ("help", re.compile(r".*Documented commands")), - ("?", re.compile(r".*Documented commands")), - ("agents", re.compile(r".*chat:")), - ("exit", re.compile(r".*Exiting ursa")), - ], - ) - print(trace) - - -async def test_chat(ursa_config): - hitl = HITL(ursa_config) - out = await hitl.run_agent( - "chat", - "What is your name?", - ) - print(out) - assert out is not None - - -@pytest.mark.slow -@pytest.mark.parametrize( - "agent", - ["chat", "execute", "hypothesize", "plan", "web"], -) -def test_agent_repl_smoke(ursa_config: UrsaConfig, agent: str): - if agent == "plan": - # Planning eats tokens - ursa_config.llm_model.max_completion_tokens = 128000 - - trace = check_script( - ursa_config, - [(f"{agent} What is your purpose?", None)], - ) - print(trace) - - -DUMMY_MCP_SERVER_PATH = Path(__file__).parent.parent.joinpath( - "tools", "dummy_mcp_server.py" -) - - -async def test_mcp_tools(ursa_config: UrsaConfig): - ursa_config.mcp_servers["demo"] = StdioServerParameters( - command=executable, - args=[str(DUMMY_MCP_SERVER_PATH.resolve())], - ) - hitl = HITL(ursa_config) - agent = await hitl.get_agent("execute") - assert agent._agent is not None - assert isinstance(agent._agent, AgentWithTools) - assert "add" in agent._agent.tools - - -@pytest.fixture -async def mcp_server(ursa_config): - hitl = HITL(ursa_config) - server = hitl.as_mcp_server() - async with Client(transport=server) as client: - yield client - - -async def test_mcp_smoke(mcp_server: Client): - tools = await mcp_server.list_tools() - assert len(tools) > 0 - await mcp_server.list_resources() - await mcp_server.list_prompts() - - -@pytest.mark.parametrize("agent,query", [("chat", "Who are you?")]) -async def test_mcp_agents(mcp_server: Client, agent: str, query: str): - response = await mcp_server.call_tool(agent, {"prompt": query}) - assert isinstance(response.structured_content["result"], str) diff --git a/tests/cli/test_runtime.py b/tests/cli/test_runtime.py new file mode 100644 index 00000000..63939503 --- /dev/null +++ b/tests/cli/test_runtime.py @@ -0,0 +1,1635 @@ +# ruff: noqa: TID251 + +import asyncio +import io +import logging +import threading +import time +from pathlib import Path +from random import random +from sys import executable +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from fastmcp.client import Client +from langgraph.checkpoint.sqlite import SqliteSaver +from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver +from mcp import StdioServerParameters +from pydantic import ValidationError +from rich.console import Console as RealConsole + +from ursa.agents.base import AgentWithTools +from ursa.cli.callbacks import HITLLogEventHandler +from ursa.cli.config import ChatModelConfig, EmbModelConfig, UrsaConfig +from ursa.cli.runtime import HITL, AgentHITL +from ursa.cli.tui.agent_info import load_agent_tools +from ursa.util.events import DEFAULT_EVENT_NAME +from ursa.util.has_optional_dep_group import has_optional_dep_group +from ursa.util.rendering import event_artifact + +LOGGER = logging.getLogger(__name__) + + +@pytest.fixture(autouse=True) +def stub_duckduckgo(monkeypatch): + class DummyDDGS: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def text(self, *args, **kwargs): + yield { + "href": "https://example.com", + "title": "Example Result", + "body": "Example summary", + } + + monkeypatch.setattr( + "ursa.agents.acquisition_agents.DDGS", + lambda: DummyDDGS(), + raising=False, + ) + monkeypatch.setattr( + "ursa.agents.hypothesizer_agent.DDGS", + lambda: DummyDDGS(), + raising=False, + ) + monkeypatch.setattr( + "ursa.cli.runtime.validate_model_provider", + lambda _config, _model_type: None, + ) + + +@pytest.fixture(scope="function") +def ursa_config(tmpdir, chat_model, embedding_model): + config = UrsaConfig( + workspace=Path(tmpdir), + llm_model=chat_model._testing_only_kwargs, + emb_model=embedding_model._testing_only_kwargs, + ) + print("ursa config:", config) # Displayed on test failure + return config + + +async def test_default_config_smoke(ursa_config): + hitl = HITL(ursa_config) + assert hitl is not None + assert set(hitl.agents.keys()) >= {"chat", "plan", "execute"} + out = await hitl.run_agent("chat", "Hello! What is your name?") + print("chat out:", out) + assert len(out) > 0 + + +DOCS_ROOT = Path(__file__).resolve().parents[2] +DOC_EXAMPLE_CONFIG = DOCS_ROOT / "configs" / "example.yaml" + + +async def test_agents_use_configured_workspace(ursa_config, tmp_path): + workspace = tmp_path / "custom-workspace" + ursa_config.workspace = workspace + + hitl = HITL(ursa_config) + agent = await hitl.get_agent("chat") + assert agent._agent is not None + assert agent._agent.workspace == workspace + + +@pytest.mark.asyncio +async def test_unnamed_cli_agent_does_not_create_checkpointer( + tmp_path, monkeypatch +): + _stub_hitl_dependencies(monkeypatch) + workspace = tmp_path / "ephemeral-workspace" + hitl = HITL(UrsaConfig(workspace=workspace)) + + async def unexpected_checkpointer(_checkpoint_path): + pytest.fail("Unnamed CLI sessions must not create a checkpointer") + + monkeypatch.setattr(hitl, "_get_checkpointer", unexpected_checkpointer) + + agent = await hitl.get_agent("chat") + + assert agent._agent is not None + assert agent._agent.checkpointer is None + assert not (workspace / "db" / "checkpointer.db").exists() + + +@pytest.mark.asyncio +async def test_named_cli_agent_still_gets_async_checkpointer( + tmp_path, monkeypatch +): + _stub_hitl_dependencies(monkeypatch) + workspace = tmp_path / "workspace" + hitl = HITL(UrsaConfig(workspace=workspace, agent_name="persistent-agent")) + persistent_den = tmp_path / "persistent-agent-den" + expected_checkpointer = object() + requested_paths = [] + + class DummyPersistentAgent: + def __init__(self, **_kwargs): + self.den = persistent_den + self.checkpointer = None + + async def fake_get_checkpointer(checkpoint_path): + requested_paths.append(checkpoint_path) + return expected_checkpointer + + hitl.agents["chat"] = AgentHITL(agent_class=DummyPersistentAgent) + monkeypatch.setattr(hitl, "_get_checkpointer", fake_get_checkpointer) + + agent = await hitl.get_agent("chat") + + assert agent._agent is not None + assert requested_paths == [persistent_den] + assert agent._agent.checkpointer is expected_checkpointer + + +async def test_concurrent_get_agent_constructs_and_finalizes_once( + tmp_path, monkeypatch +): + _stub_hitl_dependencies(monkeypatch) + hitl = HITL(UrsaConfig(workspace=tmp_path, agent_name="persistent")) + constructions = 0 + checkpointers = 0 + + class SlowPersistentAgent: + def __init__(self, **_kwargs): + nonlocal constructions + constructions += 1 + time.sleep(0.05) + self.den = tmp_path + self.checkpointer = None + + async def fake_get_checkpointer(_path): + nonlocal checkpointers + checkpointers += 1 + return object() + + wrapper = AgentHITL(agent_class=SlowPersistentAgent) + hitl.agents["chat"] = wrapper + monkeypatch.setattr(hitl, "_get_checkpointer", fake_get_checkpointer) + + first, second = await asyncio.gather( + hitl.get_agent("chat"), hitl.get_agent("chat") + ) + + assert first is second is wrapper + assert constructions == 1 + assert checkpointers == 1 + + +async def test_distinct_agents_initialize_concurrently(tmp_path, monkeypatch): + _stub_hitl_dependencies(monkeypatch) + hitl = HITL(UrsaConfig(workspace=tmp_path)) + first_started = threading.Event() + second_started = threading.Event() + release = threading.Event() + + def agent_class(started): + class SlowAgent: + def __init__(self, **_kwargs): + started.set() + release.wait(timeout=5) + + return SlowAgent + + hitl.agents = { + "first": AgentHITL(agent_class=agent_class(first_started)), + "second": AgentHITL(agent_class=agent_class(second_started)), + } + first = asyncio.create_task(hitl.get_agent("first")) + second = asyncio.create_task(hitl.get_agent("second")) + try: + assert await asyncio.to_thread(first_started.wait, 2) + assert await asyncio.to_thread(second_started.wait, 2) + finally: + release.set() + await asyncio.gather(first, second) + + +async def test_cancelled_named_get_agent_still_finishes_finalization( + tmp_path, monkeypatch +): + _stub_hitl_dependencies(monkeypatch) + hitl = HITL(UrsaConfig(workspace=tmp_path, agent_name="persistent")) + started = threading.Event() + release = threading.Event() + finalized_checkpointer = object() + finalizations = 0 + + class SlowPersistentAgent: + def __init__(self, **_kwargs): + self.den = tmp_path + self.checkpointer = None + started.set() + release.wait(timeout=5) + + async def fake_get_checkpointer(_path): + nonlocal finalizations + finalizations += 1 + return finalized_checkpointer + + wrapper = AgentHITL(agent_class=SlowPersistentAgent) + hitl.agents["chat"] = wrapper + monkeypatch.setattr(hitl, "_get_checkpointer", fake_get_checkpointer) + waiter = asyncio.create_task(hitl.get_agent("chat")) + assert await asyncio.to_thread(started.wait, 2) + waiter.cancel() + with pytest.raises(asyncio.CancelledError): + await waiter + + release.set() + await wrapper.wait_until_initialized() + loaded = await hitl.get_agent("chat") + + assert loaded is wrapper + assert wrapper._agent.checkpointer is finalized_checkpointer + assert finalizations == 1 + + +async def test_named_finalizer_failure_cleans_and_allows_retry( + tmp_path, monkeypatch +): + _stub_hitl_dependencies(monkeypatch) + hitl = HITL(UrsaConfig(workspace=tmp_path, agent_name="persistent")) + instances = [] + + class FailingConnection: + def close(self): + raise RuntimeError("sync close failed") + + class PersistentAgent: + def __init__(self, **_kwargs): + self.den = tmp_path + self.checkpointer = ( + SqliteSaver(FailingConnection()) if not instances else None + ) + self.async_closed = False + self.closed = False + instances.append(self) + + async def aclose(self): + self.async_closed = True + + def close(self): + self.closed = True + + class FailedAsyncConnection: + def __init__(self): + self.closed = False + self.joined = False + + async def close(self): + self.closed = True + + def join(self): + self.joined = True + + failed_connection = FailedAsyncConnection() + failed_checkpointer = SimpleNamespace(conn=failed_connection) + successful_checkpointer = object() + finalizer_calls = 0 + + async def fake_get_checkpointer(_path): + nonlocal finalizer_calls + finalizer_calls += 1 + return ( + failed_checkpointer + if finalizer_calls == 1 + else successful_checkpointer + ) + + wrapper = AgentHITL(agent_class=PersistentAgent) + hitl.agents["chat"] = wrapper + monkeypatch.setattr(hitl, "_get_checkpointer", fake_get_checkpointer) + + with pytest.raises(RuntimeError, match="sync close failed"): + await hitl.get_agent("chat") + + assert wrapper._agent is None + assert wrapper._initialization_task is None + assert instances[0].async_closed + assert instances[0].closed + assert failed_connection.closed + assert failed_connection.joined + assert hitl._runtime_checkpointers == [] + + loaded = await hitl.get_agent("chat") + assert loaded._agent is instances[1] + assert loaded._agent.checkpointer is successful_checkpointer + assert hitl._runtime_checkpointers == [successful_checkpointer] + + +@pytest.mark.parametrize( + "instantiate_kwargs", + [ + {}, + {"agent_name": "persistent-agent"}, + {"checkpointer": object()}, + {"agent_name": "persistent-agent", "checkpointer": object()}, + ], +) +async def test_agent_instantiation_always_runs_off_event_loop( + instantiate_kwargs, +): + event_loop_thread = threading.get_ident() + constructor_threads = [] + + class RecordingAgent: + def __init__(self, **_kwargs): + constructor_threads.append(threading.get_ident()) + + wrapper = AgentHITL(agent_class=RecordingAgent) + await wrapper.instantiate(**instantiate_kwargs) + + assert constructor_threads + assert constructor_threads[0] != event_loop_thread + + +async def test_complete_agent_loading_pipeline_does_not_block_event_loop( + monkeypatch, +): + mcp_started = threading.Event() + mcp_release = threading.Event() + + async def slow_mcp_discovery(_client): + mcp_started.set() + mcp_release.wait(timeout=5) + return [], {"remote_tool": "laboratory"} + + monkeypatch.setattr( + "ursa.agents.base.load_mcp_tools_with_sources", slow_mcp_discovery + ) + + class SlowMcpAgent(AgentWithTools): + def __init__(self, **_kwargs): + self._tools = {} + + def add_tool(self, tools): + self._tools.update({tool.name: tool for tool in tools}) + + ticks = 0 + loading = True + + async def ticker(): + nonlocal ticks + while loading: + ticks += 1 + await asyncio.sleep(0.01) + + ticker_task = asyncio.create_task(ticker()) + wrapper = AgentHITL(agent_class=SlowMcpAgent) + instantiate_task = asyncio.create_task( + wrapper.instantiate(mcp_client=object(), agent_name="persistent") + ) + try: + assert await asyncio.to_thread(mcp_started.wait, 2) + ticks_at_mcp_start = ticks + await asyncio.sleep(0.05) + assert ticks > ticks_at_mcp_start + assert not instantiate_task.done() + + mcp_release.set() + await instantiate_task + assert wrapper.tool_sources == {"remote_tool": "laboratory"} + finally: + mcp_release.set() + loading = False + await ticker_task + + +@pytest.mark.parametrize("agent_name", [None, "persistent"]) +async def test_full_get_agent_path_does_not_block_event_loop( + tmp_path, monkeypatch, agent_name +): + _stub_hitl_dependencies(monkeypatch) + hitl = HITL(UrsaConfig(workspace=tmp_path, agent_name=agent_name)) + event_loop_thread = threading.get_ident() + close_threads = [] + + class SlowConnection: + def close(self): + close_threads.append(threading.get_ident()) + time.sleep(0.05) + + class SlowAgent: + def __init__(self, **_kwargs): + time.sleep(0.05) + self.den = tmp_path + self.checkpointer = ( + SqliteSaver(SlowConnection()) if agent_name else None + ) + + async def fake_get_checkpointer(_path): + return object() + + hitl.agents["chat"] = AgentHITL(agent_class=SlowAgent) + monkeypatch.setattr(hitl, "_get_checkpointer", fake_get_checkpointer) + ticks = 0 + loading = True + + async def ticker(): + nonlocal ticks + while loading: + ticks += 1 + await asyncio.sleep(0.005) + + ticker_task = asyncio.create_task(ticker()) + await hitl.get_agent("chat") + loading = False + await ticker_task + + assert ticks > 1 + if agent_name: + assert close_threads[0] != event_loop_thread + + +async def test_agent_loading_without_mcp_skips_tool_discovery(): + class NoMcpAgent(AgentWithTools): + def __init__(self, **_kwargs): + pass + + async def add_mcp_tools(self, _client): + pytest.fail("MCP discovery must not run without an MCP client") + + wrapper = AgentHITL(agent_class=NoMcpAgent) + await wrapper.instantiate() + + assert wrapper.tool_sources == {} + + +async def test_concurrent_agent_loading_is_single_flight(): + constructor_calls = 0 + + class SlowAgent: + def __init__(self, **_kwargs): + nonlocal constructor_calls + constructor_calls += 1 + time.sleep(0.1) + + wrapper = AgentHITL(agent_class=SlowAgent) + await asyncio.gather( + wrapper.instantiate(agent_name="persistent"), + wrapper.instantiate(agent_name="persistent"), + ) + + assert constructor_calls == 1 + assert wrapper._agent is not None + + +async def test_cancelled_waiter_does_not_cancel_agent_initialization(): + started = threading.Event() + release = threading.Event() + + class SlowAgent: + def __init__(self, **_kwargs): + started.set() + release.wait(timeout=5) + + wrapper = AgentHITL(agent_class=SlowAgent) + waiter = asyncio.create_task(wrapper.instantiate()) + await asyncio.to_thread(started.wait, 2) + waiter.cancel() + with pytest.raises(asyncio.CancelledError): + await waiter + + release.set() + await wrapper.wait_until_initialized() + + assert wrapper._agent is not None + + +async def test_failed_mcp_initialization_closes_partial_agent(): + instances = [] + + class BrokenMcpAgent(AgentWithTools): + def __init__(self, **_kwargs): + self.async_closed = False + self.closed = False + instances.append(self) + + async def add_mcp_tools(self, _client): + raise RuntimeError("discovery failed") + + async def aclose(self): + self.async_closed = True + + def close(self): + self.closed = True + + wrapper = AgentHITL(agent_class=BrokenMcpAgent) + with pytest.raises(RuntimeError, match="discovery failed"): + await wrapper.instantiate(mcp_client=object()) + + assert wrapper._agent is None + assert instances[0].async_closed + assert instances[0].closed + + +async def test_concurrent_initialization_failure_cleans_once_and_can_retry(): + instances = [] + + class FlakyMcpAgent(AgentWithTools): + def __init__(self, **_kwargs): + self.attempt = len(instances) + 1 + self.close_count = 0 + instances.append(self) + + async def add_mcp_tools(self, _client): + if self.attempt == 1: + await asyncio.sleep(0.05) + raise RuntimeError("temporary failure") + return {} + + async def aclose(self): + pass + + def close(self): + self.close_count += 1 + + wrapper = AgentHITL(agent_class=FlakyMcpAgent) + failures = await asyncio.gather( + wrapper.instantiate(mcp_client=object()), + wrapper.instantiate(mcp_client=object()), + return_exceptions=True, + ) + + assert all(isinstance(error, RuntimeError) for error in failures) + assert len(instances) == 1 + assert instances[0].close_count == 1 + assert wrapper._initialization_task is None + + await wrapper.instantiate(mcp_client=object()) + assert len(instances) == 2 + assert wrapper._agent is instances[1] + + +@pytest.mark.asyncio +async def test_named_cli_agent_resources_close_once(tmp_path, monkeypatch): + _stub_hitl_dependencies(monkeypatch) + hitl = HITL( + UrsaConfig( + workspace=tmp_path / "workspace", + agent_name="persistent-agent", + ) + ) + persistent_den = tmp_path / "persistent-agent-den" + + class DummyPersistentAgent: + def __init__(self, **_kwargs): + self.den = persistent_den + self.checkpointer = None + self.async_close_count = 0 + self.close_count = 0 + + async def aclose(self): + self.async_close_count += 1 + + def close(self): + self.close_count += 1 + + hitl.agents["chat"] = AgentHITL(agent_class=DummyPersistentAgent) + wrapper = await hitl.get_agent("chat") + assert wrapper._agent is not None + agent = wrapper._agent + checkpointer = agent.checkpointer + assert isinstance(checkpointer, AsyncSqliteSaver) + assert checkpointer.conn.is_alive() + + await hitl.close() + await hitl.aclose() + + assert agent.async_close_count == 1 + assert agent.close_count == 1 + assert wrapper._agent is None + assert checkpointer.conn._connection is None + assert not checkpointer.conn.is_alive() + + +async def test_closed_runtime_rejects_agent_loading_without_waiting( + tmp_path, monkeypatch +): + _stub_hitl_dependencies(monkeypatch) + hitl = HITL(UrsaConfig(workspace=tmp_path)) + await asyncio.gather(hitl.aclose(), hitl.aclose()) + + with pytest.raises(RuntimeError, match="runtime is closed"): + await asyncio.wait_for(hitl.get_agent("chat"), timeout=0.5) + with pytest.raises(RuntimeError, match="runtime is closed"): + await hitl.reconfigure_model("openai:gpt-5.4", "openai") + with pytest.raises(RuntimeError, match="runtime is closed"): + await hitl.reconfigure_models( + ChatModelConfig( + model="openai:gpt-5.4", inference_provider="openai" + ), + None, + ) + + +async def test_cancelled_close_waiter_does_not_interrupt_cleanup( + tmp_path, monkeypatch +): + _stub_hitl_dependencies(monkeypatch) + hitl = HITL(UrsaConfig(workspace=tmp_path)) + close_started = threading.Event() + close_release = threading.Event() + + class SlowCloseAgent: + async def aclose(self): + pass + + def close(self): + close_started.set() + close_release.wait(timeout=5) + + wrapper = AgentHITL(agent_class=SlowCloseAgent) + wrapper._agent = SlowCloseAgent() + hitl.agents["chat"] = wrapper + waiter = asyncio.create_task(hitl.aclose()) + assert await asyncio.to_thread(close_started.wait, 2) + internal_close = hitl._close_task + assert internal_close is not None + waiter.cancel() + with pytest.raises(asyncio.CancelledError): + await waiter + + close_release.set() + await internal_close + + assert hitl._closed + assert wrapper._agent is None + + +async def test_reconfigure_models_resets_agents_and_uses_selected_providers( + tmp_path, monkeypatch +): + initial_model, _ = _stub_hitl_dependencies(monkeypatch) + replacement_model = MagicMock(name="replacement-llm") + replacement_embedding = MagicMock(name="replacement-embedding") + hitl = HITL(UrsaConfig(workspace=tmp_path)) + + class DummyAgent: + checkpointer = None + + def __init__(self, **_kwargs): + self.async_closed = False + self.closed = False + + async def aclose(self): + self.async_closed = True + + def close(self): + self.closed = True + + wrapper = AgentHITL( + agent_class=DummyAgent, + config={"rag_tool_embedding": None}, + ) + hitl.agents["chat"] = wrapper + await hitl.get_agent("chat") + old_agent = wrapper._agent + monkeypatch.setattr( + "ursa.cli.config.init_chat_model", lambda **_: replacement_model + ) + monkeypatch.setattr( + "ursa.cli.config.init_embeddings", lambda **_: replacement_embedding + ) + + await hitl.reconfigure_models( + ChatModelConfig(model="openai:gpt-5.4", inference_provider="openai"), + EmbModelConfig( + model="openai:text-embedding-3-large", + inference_provider="openai", + ), + ) + + assert hitl.model is replacement_model + assert hitl.model is not initial_model + assert hitl.config.llm_model.model == "gpt-5.4" + assert hitl.config.llm_model.model_provider == "openai" + assert hitl.embedding is replacement_embedding + assert hitl.config.emb_model.model == "text-embedding-3-large" + assert hitl.config.emb_model.model_provider == "openai" + assert wrapper.config["rag_tool_embedding"] is replacement_embedding + assert wrapper._agent is None + assert old_agent.async_closed + assert old_agent.closed + + +async def test_reconfigure_waits_for_loading_then_discards_old_model_agent( + tmp_path, monkeypatch +): + _stub_hitl_dependencies(monkeypatch) + replacement_model = MagicMock(name="replacement-llm") + hitl = HITL(UrsaConfig(workspace=tmp_path)) + started = threading.Event() + release = threading.Event() + instances = [] + + class SlowAgent: + checkpointer = None + + def __init__(self, llm, **_kwargs): + self.llm = llm + self.async_closed = False + self.closed = False + instances.append(self) + started.set() + release.wait(timeout=5) + + async def aclose(self): + self.async_closed = True + + def close(self): + self.closed = True + + wrapper = AgentHITL(agent_class=SlowAgent) + hitl.agents["chat"] = wrapper + monkeypatch.setattr( + "ursa.cli.config.init_chat_model", lambda **_: replacement_model + ) + + load_task = asyncio.create_task(hitl.get_agent("chat")) + assert await asyncio.to_thread(started.wait, 2) + reconfigure_task = asyncio.create_task( + hitl.reconfigure_models( + ChatModelConfig( + model="openai:gpt-5.4", inference_provider="openai" + ), + None, + ) + ) + await asyncio.sleep(0) + release.set() + await asyncio.gather(load_task, reconfigure_task) + # Reconfiguration acquired the lifecycle lock after loading, closed the + # old-model instance, and reset the wrapper for the next lazy load. + assert wrapper._agent is None + assert hitl.model is replacement_model + assert instances[0].async_closed + assert instances[0].closed + + +async def test_reconfigure_waits_for_active_agent_run(tmp_path, monkeypatch): + _stub_hitl_dependencies(monkeypatch) + replacement_model = MagicMock(name="replacement-llm") + hitl = HITL(UrsaConfig(workspace=tmp_path)) + run_started = asyncio.Event() + run_release = asyncio.Event() + instances = [] + close_threads = [] + event_loop_thread = threading.get_ident() + + class RunningAgent: + checkpointer = None + + def __init__(self, **_kwargs): + self.async_closed = False + self.closed = False + instances.append(self) + + async def aclose(self): + self.async_closed = True + + def close(self): + close_threads.append(threading.get_ident()) + time.sleep(0.05) + self.closed = True + + class RunningWrapper(AgentHITL): + async def __call__(self, *_args, **_kwargs): + run_started.set() + await run_release.wait() + return "complete" + + hitl.agents["chat"] = RunningWrapper(agent_class=RunningAgent) + monkeypatch.setattr( + "ursa.cli.config.init_chat_model", lambda **_: replacement_model + ) + run_task = asyncio.create_task(hitl.run_agent("chat", "work")) + await run_started.wait() + reconfigure_task = asyncio.create_task( + hitl.reconfigure_models( + ChatModelConfig( + model="openai:gpt-5.4", inference_provider="openai" + ), + None, + ) + ) + await asyncio.sleep(0.05) + + assert not reconfigure_task.done() + assert not instances[0].closed + run_release.set() + result, _ = await asyncio.gather(run_task, reconfigure_task) + + assert result == "complete" + assert instances[0].async_closed + assert instances[0].closed + assert close_threads[0] != event_loop_thread + + +async def test_cancelled_reconfigure_waiter_does_not_interrupt_transition( + tmp_path, monkeypatch +): + _stub_hitl_dependencies(monkeypatch) + replacement_model = MagicMock(name="replacement-llm") + hitl = HITL(UrsaConfig(workspace=tmp_path)) + close_started = threading.Event() + close_release = threading.Event() + + class SlowCloseAgent: + checkpointer = None + + async def aclose(self): + pass + + def close(self): + close_started.set() + close_release.wait(timeout=5) + + wrapper = AgentHITL(agent_class=SlowCloseAgent) + wrapper._agent = SlowCloseAgent() + hitl.agents["chat"] = wrapper + monkeypatch.setattr( + "ursa.cli.config.init_chat_model", lambda **_: replacement_model + ) + waiter = asyncio.create_task( + hitl.reconfigure_models( + ChatModelConfig( + model="openai:gpt-5.4", inference_provider="openai" + ), + None, + ) + ) + assert await asyncio.to_thread(close_started.wait, 2) + waiter.cancel() + with pytest.raises(asyncio.CancelledError): + await waiter + + close_release.set() + async with asyncio.timeout(2): + while hitl.model is not replacement_model: + await asyncio.sleep(0.01) + + assert wrapper._agent is None + assert hitl._loads_allowed.is_set() + + +async def test_reconfigure_waits_for_tool_schema_snapshot( + tmp_path, monkeypatch +): + _stub_hitl_dependencies(monkeypatch) + replacement_model = MagicMock(name="replacement-llm") + hitl = HITL(UrsaConfig(workspace=tmp_path)) + schema_started = threading.Event() + schema_release = threading.Event() + + class SlowSchema: + @classmethod + def model_json_schema(cls): + schema_started.set() + schema_release.wait(timeout=5) + return {"properties": {}} + + class Tool: + name = "slow_tool" + description = "Slow schema tool" + args_schema = SlowSchema + return_direct = False + + class InitializedAgent: + checkpointer = None + + def __init__(self): + self.tools = {"slow_tool": Tool()} + self.closed = False + + async def aclose(self): + pass + + def close(self): + self.closed = True + + initialized = InitializedAgent() + wrapper = AgentHITL(agent_class=InitializedAgent) + wrapper._agent = initialized + hitl.agents["chat"] = wrapper + monkeypatch.setattr( + "ursa.cli.config.init_chat_model", lambda **_: replacement_model + ) + + snapshot_task = asyncio.create_task(load_agent_tools(hitl, "chat")) + assert await asyncio.to_thread(schema_started.wait, 2) + reconfigure_task = asyncio.create_task( + hitl.reconfigure_models( + ChatModelConfig( + model="openai:gpt-5.4", inference_provider="openai" + ), + None, + ) + ) + await asyncio.sleep(0.05) + + assert not reconfigure_task.done() + assert not initialized.closed + schema_release.set() + tools, _ = await asyncio.gather(snapshot_task, reconfigure_task) + + assert [tool.name for tool in tools] == ["slow_tool"] + assert initialized.closed + + +async def test_cancelled_schema_snapshot_holds_lease_until_thread_finishes( + tmp_path, monkeypatch +): + _stub_hitl_dependencies(monkeypatch) + hitl = HITL(UrsaConfig(workspace=tmp_path)) + schema_started = threading.Event() + schema_release = threading.Event() + + class SlowSchema: + @classmethod + def model_json_schema(cls): + schema_started.set() + schema_release.wait(timeout=5) + return {"properties": {}} + + class Tool: + name = "slow_tool" + description = "Slow schema tool" + args_schema = SlowSchema + return_direct = False + + class InitializedAgent: + checkpointer = None + + def __init__(self): + self.tools = {"slow_tool": Tool()} + self.closed = False + + async def aclose(self): + pass + + def close(self): + self.closed = True + + initialized = InitializedAgent() + wrapper = AgentHITL(agent_class=InitializedAgent) + wrapper._agent = initialized + hitl.agents["chat"] = wrapper + + snapshot_task = asyncio.create_task(load_agent_tools(hitl, "chat")) + assert await asyncio.to_thread(schema_started.wait, 2) + snapshot_task.cancel() + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(snapshot_task, 0.5) + + close_task = asyncio.create_task(hitl.aclose()) + await asyncio.sleep(0.05) + + assert not close_task.done() + assert not initialized.closed + + schema_release.set() + await close_task + + assert initialized.closed + + +async def test_close_waits_for_active_agent_run(tmp_path, monkeypatch): + _stub_hitl_dependencies(monkeypatch) + hitl = HITL(UrsaConfig(workspace=tmp_path)) + run_started = asyncio.Event() + run_release = asyncio.Event() + close_count = 0 + + class RunningAgent: + checkpointer = None + + def __init__(self, **_kwargs): + pass + + async def aclose(self): + pass + + def close(self): + nonlocal close_count + close_count += 1 + + class RunningWrapper(AgentHITL): + async def __call__(self, *_args, **_kwargs): + run_started.set() + await run_release.wait() + return "complete" + + hitl.agents["chat"] = RunningWrapper(agent_class=RunningAgent) + run_task = asyncio.create_task(hitl.run_agent("chat", "work")) + await run_started.wait() + close_task = asyncio.create_task(hitl.aclose()) + await asyncio.sleep(0.05) + + assert not close_task.done() + assert close_count == 0 + run_release.set() + result, _ = await asyncio.gather(run_task, close_task) + + assert result == "complete" + assert close_count == 1 + assert hitl._closed + + +def _stub_hitl_dependencies(monkeypatch): + fake_llm = MagicMock(name="llm") + fake_embedding = MagicMock(name="embedding") + monkeypatch.setattr("ursa.cli.config.init_chat_model", lambda **_: fake_llm) + monkeypatch.setattr( + "ursa.cli.config.init_embeddings", lambda **_: fake_embedding + ) + monkeypatch.setattr( + "ursa.cli.runtime.start_mcp_client", lambda servers: None + ) + return fake_llm, fake_embedding + + +def test_hitl_startup_reports_provider_validation_failure( + tmp_path, monkeypatch +): + _stub_hitl_dependencies(monkeypatch) + monkeypatch.setattr( + "ursa.cli.runtime.validate_model_provider", + MagicMock(side_effect=ValueError("API key is missing")), + ) + + with pytest.raises( + ValueError, + match="API key is missing", + ): + HITL(UrsaConfig(workspace=tmp_path)) + + +async def test_reconfigure_models_reports_validation_failure_without_change( + tmp_path, monkeypatch +): + initial_model, _ = _stub_hitl_dependencies(monkeypatch) + hitl = HITL(UrsaConfig(workspace=tmp_path)) + monkeypatch.setattr( + "ursa.cli.runtime.validate_model_provider", + MagicMock(side_effect=ValueError("model is unavailable")), + ) + + with pytest.raises( + ValueError, + match="model is unavailable", + ): + await hitl.reconfigure_models( + ChatModelConfig( + model="missing-model", + model_provider="openai", + inference_provider="openai", + ), + None, + ) + + assert hitl.model is initial_model + assert hitl.config.llm_model.model == "gpt-5.4" + + +@pytest.mark.parametrize( + "agent_name", + [ + "chat", + "arxiv", + "execute", + "hypothesize", + "plan", + "web", + ] + + (["dsi"] if has_optional_dep_group("dsi") else []), +) +async def test_agents_apply_agent_config_overrides( + agent_name, tmp_path, monkeypatch +): + _stub_hitl_dependencies(monkeypatch) + + config = UrsaConfig( + workspace=tmp_path / "global-workspace", + emb_model=EmbModelConfig(model="fake-embedding"), + ) + + overrides = {} + overrides[agent_name] = { + "workspace": tmp_path / f"{agent_name}-workspace", + "enable_metrics": random() > 0.5, + } + + config.agent_config = overrides + + hitl = HITL(config) + + agent = await hitl.get_agent(agent_name) + override = overrides[agent_name] + assert agent._agent is not None + assert agent._agent.workspace == override["workspace"] + assert agent._agent.telemetry.enable == override["enable_metrics"] + + +@pytest.mark.asyncio +async def test_thread_id_propagates_from_config(tmp_path, monkeypatch): + _stub_hitl_dependencies(monkeypatch) + config = UrsaConfig( + workspace=tmp_path / "global-workspace", + thread_id="custom-thread", + emb_model=EmbModelConfig(model="fake-embedding"), + ) + + hitl = HITL(config) + assert hitl.thread_id == "custom-thread" + + agent = await hitl.get_agent("chat") + assert agent._agent is not None + assert agent._agent.thread_id == "custom-thread" + + +@pytest.mark.asyncio +async def test_hitl_run_agent_forwards_callbacks(tmp_path, monkeypatch): + _stub_hitl_dependencies(monkeypatch) + config = UrsaConfig( + workspace=tmp_path / "global-workspace", + emb_model=EmbModelConfig(model="fake-embedding"), + ) + hitl = HITL(config) + captured = {} + previous_agent = object() + current_agent = object() + + class DummyAgent: + _agent = current_agent + + async def __call__( + self, + prompt: str, + last_agent_result: str | None = None, + last_agent=None, + callbacks=None, + ) -> str: + captured["prompt"] = prompt + captured["last_agent_result"] = last_agent_result + captured["last_agent"] = last_agent + captured["callbacks"] = callbacks + return "agent result" + + async def fake_get_agent(name: str): + assert name == "chat" + return DummyAgent() + + hitl.last_agent_result = "previous result" + hitl.last_agent = previous_agent + monkeypatch.setattr(hitl, "_get_agent", fake_get_agent) + + callbacks = ["callback-1"] + result = await hitl.run_agent("chat", "hello", callbacks=callbacks) + + assert result == "agent result" + assert captured == { + "prompt": "hello", + "last_agent_result": "previous result", + "last_agent": previous_agent, + "callbacks": callbacks, + } + assert hitl.last_agent_result == "agent result" + assert hitl.last_agent is current_agent + + +@pytest.mark.asyncio +async def test_agent_hitl_passes_extra_callbacks_only(): + captured = {} + custom_callback = object() + + class DummyAgent: + telemetry = type("Telemetry", (), {"callbacks": ["telemetry"]})() + + def format_query(self, prompt: str, state=None): + captured["prompt"] = prompt + captured["state"] = state + return {"messages": [prompt]} + + async def ainvoke(self, query, config=None): + captured["query"] = query + captured["config"] = config + return {"messages": ["done"]} + + def format_result(self, result): + return "done" + + wrapper = AgentHITL(agent_class=object) + wrapper._agent = DummyAgent() + + result = await wrapper("hello", callbacks=[custom_callback]) + + assert result == "done" + assert captured["prompt"] == "hello" + assert captured["state"] is None + assert captured["query"] == {"messages": ["hello"]} + assert captured["config"] == {"callbacks": [custom_callback]} + + +def test_hitl_log_event_handler_renders_events(tmp_path): + output = io.StringIO() + console = RealConsole( + file=output, + force_terminal=False, + force_interactive=False, + color_system=None, + width=80, + ) + handler = HITLLogEventHandler(console=console, workspace=tmp_path) + + asyncio.run( + handler.on_custom_event( + DEFAULT_EVENT_NAME, + { + "agent": "PlanningAgent", + "stage": "reflect_result", + "message": "Plan needs another pass", + "approved": False, + "reason": "Need one more concrete step.", + }, + run_id="agent-run", + ) + ) + asyncio.run( + handler.on_custom_event( + DEFAULT_EVENT_NAME, + { + "tool": "edit_code", + "stage": "edit", + "phase": "end", + "message": "File updated", + "artifact": event_artifact( + "--- app.py\n+++ app.py\n-old\n+new", + "text/x-diff", + metadata={ + "title": "Edit diff", + "path": "repo/app.py", + }, + ), + }, + run_id="edit-tool-artifact-run", + ) + ) + asyncio.run( + handler.on_tool_start( + {"name": "run_command"}, + '{"query":"uname -s"}', + run_id="tool-run", + inputs={"query": "uname -s"}, + ) + ) + asyncio.run( + handler.on_custom_event( + DEFAULT_EVENT_NAME, + { + "tool": "run_command", + "stage": "execute", + "phase": "end", + "message": "Command finished", + "artifacts": [ + event_artifact( + "Darwin", + "text/plain", + metadata={"title": "stdout"}, + ), + event_artifact( + "warning", + "text/plain", + metadata={"title": "stderr"}, + ), + ], + }, + run_id="tool-run", + ) + ) + asyncio.run( + handler.on_tool_end( + "STDOUT:\nDarwin\nSTDERR:\n", + run_id="tool-run", + ) + ) + asyncio.run( + handler.on_tool_start( + {"name": "write_code_with_repo"}, + '{"filename":"repo/app.py"}', + run_id="write-tool-run", + inputs={"filename": "repo/app.py"}, + ) + ) + asyncio.run( + handler.on_tool_end( + "File repo/app.py written successfully.", + run_id="write-tool-run", + ) + ) + asyncio.run( + handler.on_custom_event( + DEFAULT_EVENT_NAME, + { + "tool": "edit_code", + "stage": "edit", + "phase": "start", + "message": "Editing file", + "path": str(tmp_path / "repo" / "app.py"), + }, + run_id="edit-tool-run", + ) + ) + asyncio.run( + handler.on_custom_event( + DEFAULT_EVENT_NAME, + { + "tool": "edit_code", + "stage": "edit", + "message": "No changes made", + "filename": "repo/app.py", + "reason": "'old_code' not found in file.", + }, + run_id="edit-tool-noop-run", + ) + ) + asyncio.run( + handler.on_custom_event( + DEFAULT_EVENT_NAME, + { + "tool": "run_web_search", + "stage": "search", + "message": "Searching Web", + "query": "ursa events", + }, + run_id="search-tool-run", + ) + ) + asyncio.run( + handler.on_custom_event( + DEFAULT_EVENT_NAME, + { + "tool": "run_web_search", + "stage": "search_result", + "message": "Web search complete", + "query": "ursa events", + "result_chars": 42, + }, + run_id="search-tool-result-run", + ) + ) + asyncio.run( + handler.on_custom_event( + DEFAULT_EVENT_NAME, + { + "agent": "LammpsAgent", + "stage": "choose_potential", + "phase": "end", + "message": "Potential chosen", + "chosen_index": 2, + "potential_id": "pot-2", + "rationale": "Best fit for the requested elements.", + }, + run_id="lammps-choice-run", + ) + ) + asyncio.run( + handler.on_custom_event( + DEFAULT_EVENT_NAME, + { + "agent": "LammpsAgent", + "stage": "author_input", + "phase": "end", + "message": "LAMMPS input authored", + "preview": "units metal\nrun 100", + "language": "bash", + "path": str(tmp_path / "in.lammps"), + }, + run_id="lammps-author-run", + ) + ) + asyncio.run( + handler.on_custom_event( + DEFAULT_EVENT_NAME, + { + "agent": "LammpsAgent", + "stage": "fix_input", + "phase": "end", + "message": "LAMMPS input rewritten", + "old_code": "run 100", + "new_code": "run 200", + "path": str(tmp_path / "in.lammps"), + }, + run_id="lammps-fix-run", + ) + ) + asyncio.run( + handler.on_custom_event( + DEFAULT_EVENT_NAME, + { + "agent": "LammpsAgent", + "stage": "run", + "phase": "error", + "message": "LAMMPS run failed", + "returncode": 1, + "error_output": "ERROR: Invalid pair style", + }, + run_id="lammps-failed-run", + ) + ) + + rendered = output.getvalue() + + assert "Edit diff" in rendered + assert "-old" in rendered + assert "+new" in rendered + + repo_app_string = str( + Path("repo") / "app.py" + ) # Rendering OS specific path string + + assert "Plan" in rendered + assert "Plan needs another pass" in rendered + assert "Need one more concrete step." in rendered + assert "Running command: uname -s" in rendered + assert "Command finished: uname -s" in rendered + assert "stdout" in rendered + assert "stderr" in rendered + assert "warning" in rendered + assert any( + "stdout" in line and "stderr" in line for line in rendered.splitlines() + ) + assert "Darwin" in rendered + assert f"Writing file: {repo_app_string}" in rendered + assert f"File written: {repo_app_string}" in rendered + assert f"Editing file: {repo_app_string}" in rendered + assert f"No changes made: {repo_app_string}" in rendered + assert "'old_code' not found in file." in rendered + assert "Searching Web: ursa events" in rendered + assert "Web search complete: ursa events" in rendered + assert "42 chars" in rendered + assert "LAMMPS" in rendered + assert "Chosen Potential" in rendered + assert "pot-2" in rendered + assert "Best fit for the requested elements." in rendered + assert "LAMMPS input authored" in rendered + assert "units metal" in rendered + assert "LAMMPS input diff" in rendered + assert "run 100" in rendered + assert "run 200" in rendered + assert "Run error/output" in rendered + assert "ERROR: Invalid pair style" in rendered + + +def test_hitl_log_event_handler_renders_named_agent_tool_artifacts(tmp_path): + output = io.StringIO() + console = RealConsole( + file=output, + force_terminal=False, + force_interactive=False, + color_system=None, + width=80, + ) + handler = HITLLogEventHandler(console=console, workspace=tmp_path) + + async def emit_events() -> None: + await handler.on_custom_event( + DEFAULT_EVENT_NAME, + { + "agent": "dummy_bot_3000", + "tool": "write_code", + "stage": "write", + "phase": "end", + "message": "File written", + "filename": "first_10_integers.py", + "artifact": event_artifact( + "for i in range(1, 11):\n print(i)\n", + "text/x-python", + metadata={"title": "File written"}, + ), + }, + run_id="named-write-tool-run", + ) + await handler.on_custom_event( + DEFAULT_EVENT_NAME, + { + "agent": "dummy_bot_3000", + "tool": "run_command", + "stage": "execute", + "phase": "end", + "message": "Command finished", + "query": "python first_10_integers.py", + "artifacts": [ + event_artifact( + "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n", + "text/plain", + metadata={"title": "stdout"}, + ) + ], + }, + run_id="named-command-tool-run", + ) + + asyncio.run(emit_events()) + + rendered = output.getvalue() + assert "File written" in rendered + assert "for i in range(1, 11):" in rendered + assert "print(i)" in rendered + assert "stdout" in rendered + assert "1" in rendered + assert "10" in rendered + + +def test_agent_config_unknown_agent_raises(tmp_path, monkeypatch): + _stub_hitl_dependencies(monkeypatch) + config = UrsaConfig( + workspace=tmp_path / "global-workspace", + emb_model=EmbModelConfig(model="fake-embedding"), + ) + config.agent_config = { + "ghost": {"workspace": tmp_path / "ghost-workspace"}, + } + + with pytest.raises(AssertionError, match="Unknown agent ghost"): + HITL(config) + + +def test_agent_config_none_value_errors(tmp_path, monkeypatch): + _stub_hitl_dependencies(monkeypatch) + config = UrsaConfig( + workspace=tmp_path / "global-workspace", + emb_model=EmbModelConfig(model="fake-embedding"), + ) + with pytest.raises(ValidationError): + config.agent_config = {"chat": None} + + +@pytest.mark.asyncio +async def test_agent_config_unknown_option_raises(tmp_path, monkeypatch): + _stub_hitl_dependencies(monkeypatch) + config = UrsaConfig( + workspace=tmp_path / "global-workspace", + emb_model=EmbModelConfig(model="fake-embedding"), + ) + config.agent_config = {"chat": {"nonexistent_option": True}} + + hitl = HITL(config) + + with pytest.raises(TypeError, match="nonexistent_option"): + await hitl.get_agent("chat") + + +async def test_chat(ursa_config): + hitl = HITL(ursa_config) + out = await hitl.run_agent( + "chat", + "What is your name?", + ) + print(out) + assert out is not None + + +DUMMY_MCP_SERVER_PATH = Path(__file__).parent.parent.joinpath( + "tools", "dummy_mcp_server.py" +) + + +async def test_mcp_tools(ursa_config: UrsaConfig): + ursa_config.mcp_servers["demo"] = StdioServerParameters( + command=executable, + args=[str(DUMMY_MCP_SERVER_PATH.resolve())], + ) + hitl = HITL(ursa_config) + agent = await hitl.get_agent("execute") + assert agent._agent is not None + assert isinstance(agent._agent, AgentWithTools) + assert "add" in agent._agent.tools + assert agent.tool_sources["add"] == "demo" + + +@pytest.fixture +async def mcp_server(ursa_config): + hitl = HITL(ursa_config) + server = hitl.as_mcp_server() + async with Client(transport=server) as client: + yield client + + +async def test_mcp_smoke(mcp_server: Client): + tools = await mcp_server.list_tools() + assert len(tools) > 0 + await mcp_server.list_resources() + await mcp_server.list_prompts() + + +@pytest.mark.parametrize("agent,query", [("chat", "Who are you?")]) +async def test_mcp_agents(mcp_server: Client, agent: str, query: str): + response = await mcp_server.call_tool(agent, {"prompt": query}) + assert isinstance(response.structured_content["result"], str) diff --git a/tests/cli/tui/__init__.py b/tests/cli/tui/__init__.py new file mode 100644 index 00000000..0cd8f2ac --- /dev/null +++ b/tests/cli/tui/__init__.py @@ -0,0 +1 @@ +"""Tests for the Textual command-line interface.""" diff --git a/tests/cli/tui/event_cards/test_commands.py b/tests/cli/tui/event_cards/test_commands.py new file mode 100644 index 00000000..2f6b4ef0 --- /dev/null +++ b/tests/cli/tui/event_cards/test_commands.py @@ -0,0 +1,383 @@ +import asyncio + +from textual.containers import VerticalScroll +from textual.widgets import Static + +from tests.cli._app_fakes import FakeHITL, emit_event +from ursa.cli.tui.app import UrsaTextualApp +from ursa.cli.tui.event_cards import CommandSafetyIndicator, RunCommandCard +from ursa.cli.tui.event_handler import TextualEventHandler +from ursa.cli.tui.turn import Turn +from ursa.cli.tui.widgets import ActivityIndicator +from ursa.util.events import DEFAULT_EVENT_NAME + + +def test_long_command_preview_keeps_top_and_bottom_eight_lines(): + command = "\n".join(f"line {index}" for index in range(1, 26)) + + preview = RunCommandCard._preview_command(command) + + assert preview.splitlines() == [ + *(f"line {index}" for index in range(1, 9)), + "… 9 lines omitted …", + *(f"line {index}" for index in range(18, 26)), + ] + + +async def test_overlapping_commands_stay_compact_and_complete_independently( + tmp_path, +): + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test(size=(100, 36)) as pilot: + turn = Turn("run both", tmp_path) + await app.query_one("#conversation", VerticalScroll).mount(turn) + for command_id, query in (("one", "sleep 1"), ("two", "sleep 2")): + await turn.event({ + "tool": "run_command", + "phase": "start", + "query": query, + "_command_id": command_id, + }) + await pilot.pause() + + cards = list(turn.query(RunCommandCard)) + assert len(cards) == 2 + assert all(card.multi_command for card in cards) + assert all( + not card.query_one(".command-compact").has_class("hidden") + for card in cards + ) + + await turn.event({ + "tool": "run_command", + "phase": "end", + "query": "sleep 1", + "_command_id": "one", + "returncode": 0, + "result": "", + }) + await pilot.pause() + assert cards[0].completed + assert cards[0].returncode == 0 + assert not cards[1].completed + assert ( + str(cards[1].query_one(".command-compact-state", Static).content) + in ActivityIndicator.FRAMES + ) + + +async def test_identical_concurrent_commands_are_correlated_by_run_id(tmp_path): + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test(size=(100, 36)) as pilot: + turn = Turn("run both", tmp_path) + await app.query_one("#conversation", VerticalScroll).mount(turn) + handler = TextualEventHandler(app, turn) + for run_id in ("one", "two"): + await handler.on_tool_start( + {"name": "run_command"}, + "", + run_id=run_id, + inputs={"query": "echo same"}, + ) + await handler.on_tool_error(RuntimeError("first failed"), run_id="one") + await handler.on_custom_event( + DEFAULT_EVENT_NAME, + { + "tool": "run_command", + "stage": "safety_check", + "query": "echo same", + "safe": True, + }, + run_id="child-event-run", + ) + await handler.on_tool_end("second output", run_id="two") + await pilot.pause() + + first, second = turn.query(RunCommandCard) + assert first.query_one(CommandSafetyIndicator).status == "unavailable" + assert first.query_one(".command-compact-state", Static).content == "✗" + assert second.query_one(CommandSafetyIndicator).status == "passed" + assert second.query_one(".command-compact-state", Static).content == "✓" + assert ( + second.query_one(".command-output", Static).content.code + == "second output" + ) + + +async def test_command_completion_finishes_pending_safety_indicator(tmp_path): + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test(size=(100, 36)) as pilot: + turn = Turn("run it", tmp_path) + await app.query_one("#conversation", VerticalScroll).mount(turn) + await turn.event({ + "tool": "run_command", + "phase": "start", + "query": "uptime", + "_command_id": "uptime", + }) + await turn.event({ + "tool": "run_command", + "phase": "end", + "query": "uptime", + "_command_id": "uptime", + "result": "up 10 days", + }) + await pilot.pause() + + safety = turn.query_one(CommandSafetyIndicator) + assert safety.status == "passed" + + +async def test_solitary_command_after_overlap_returns_to_detailed_layout( + tmp_path, +): + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test(size=(100, 36)) as pilot: + turn = Turn("run commands", tmp_path) + await app.query_one("#conversation", VerticalScroll).mount(turn) + for command_id in ("one", "two"): + await turn.event({ + "tool": "run_command", + "phase": "start", + "query": command_id, + "_command_id": command_id, + }) + for command_id in ("one", "two"): + await turn.event({ + "tool": "run_command", + "phase": "end", + "query": command_id, + "_command_id": command_id, + "returncode": 0, + "result": "done", + }) + await turn.event({ + "tool": "run_command", + "phase": "start", + "query": "three", + "_command_id": "three", + }) + await pilot.pause() + + first, second, third = turn.query(RunCommandCard) + assert first.multi_command + assert second.multi_command + assert not third.multi_command + assert third.query_one(".command-compact").has_class("hidden") + + +async def test_run_command_card_tracks_safety_and_collapses_on_result(tmp_path): + hitl = FakeHITL(tmp_path) + pass_safety = asyncio.Event() + return_result = asyncio.Event() + return_second = asyncio.Event() + command = "\n".join(f"echo line-{index}" for index in range(1, 7)) + + async def run_agent(_name, _prompt, callbacks=None): + handler = callbacks[0] + await handler.on_tool_start( + {"name": "run_command"}, + "", + run_id="command-1", + inputs={"query": command}, + ) + await pass_safety.wait() + await emit_event( + handler, + { + "tool": "run_command", + "stage": "safety_check", + "message": "Command passed safety check", + "query": command, + "safe": True, + }, + ) + await return_result.wait() + await handler.on_tool_end( + "STDOUT:\ncommand output\nSTDERR:\n", run_id="command-1" + ) + await handler.on_tool_start( + {"name": "run_command"}, + "", + run_id="command-2", + inputs={"query": "echo second"}, + ) + await emit_event( + handler, + { + "tool": "run_command", + "stage": "safety_check", + "message": "Command passed safety check", + "query": "echo second", + "safe": True, + }, + ) + await return_second.wait() + await handler.on_tool_end( + "STDOUT:\nsecond output\nSTDERR:\n", run_id="command-2" + ) + return "Finished" + + hitl.run_agent = run_agent + app = UrsaTextualApp(hitl) + + async with app.run_test(size=(100, 36)) as pilot: + await pilot.press("r", "u", "n", "enter") + await pilot.pause() + card = app.query_one(RunCommandCard) + source = card.query_one(".command-source", Static) + safety = card.query_one(CommandSafetyIndicator) + assert len(source.content.code.splitlines()) == 6 + assert ( + str(safety.query_one(".activity-text", Static).content) + == "Running safety check" + ) + + pass_safety.set() + await pilot.pause() + assert safety.status == "passed" + + return_result.set() + await pilot.pause() + assert source.content.code == "echo line-1 …" + output = card.query_one(".command-output", Static) + assert output.content.code == "command output" + cards = list(app.query(RunCommandCard)) + assert len(cards) == 2 + assert [item.command for item in cards] == [command, "echo second"] + assert output.has_class("hidden") + assert not card.query_one(".command-compact").has_class("hidden") + + return_second.set() + await pilot.pause() + assert card.returncode is None + assert card.query_one(".command-compact-state", Static).content == "✓" + newest = cards[-1] + assert not newest.query_one(".command-compact").has_class("hidden") + assert newest.query_one(".command-output").has_class("hidden") + + await pilot.press("ctrl+o") + assert not output.has_class("hidden") + assert source.content.code == command + + await pilot.press("ctrl+o") + assert output.has_class("hidden") + assert source.content.code == "echo line-1 …" + + +async def test_single_command_output_preserves_top_and_bottom_until_expanded( + tmp_path, +): + hitl = FakeHITL(tmp_path) + full_output = "\n".join(f"line {index}" for index in range(1, 31)) + + async def run_agent(_name, _prompt, callbacks=None): + handler = callbacks[0] + await handler.on_tool_start( + {"name": "run_command"}, + "", + run_id="long-output", + inputs={"query": "generate output"}, + ) + await emit_event( + handler, + { + "tool": "run_command", + "stage": "safety_check", + "query": "generate output", + "safe": True, + }, + ) + await handler.on_tool_end(full_output, run_id="long-output") + return "Finished" + + hitl.run_agent = run_agent + app = UrsaTextualApp(hitl) + + async with app.run_test(size=(100, 36)) as pilot: + await pilot.press("r", "u", "n", "enter") + await pilot.pause() + output = app.query_one(".command-output", Static) + preview = output.content.code + assert len(preview.splitlines()) == 9 + assert "line 1\n" in preview + assert "line 4\n… 22 lines omitted …\nline 27" in preview + assert preview.endswith("line 30") + + await pilot.press("ctrl+o") + assert output.content.code == full_output + + +async def test_collapsed_commands_retain_execution_outcomes(tmp_path): + hitl = FakeHITL(tmp_path) + + async def run_agent(_name, _prompt, callbacks=None): + handler = callbacks[0] + + async def command( + run_id, + query, + *, + safe=True, + returncode=None, + output="", + ): + await handler.on_tool_start( + {"name": "run_command"}, + "", + run_id=run_id, + inputs={"query": query}, + ) + await emit_event( + handler, + { + "tool": "run_command", + "stage": "safety_check", + "query": query, + "safe": safe, + "reason": "Rejected" if not safe else "Allowed", + }, + ) + if returncode is not None: + await emit_event( + handler, + { + "tool": "run_command", + "stage": "execute", + "phase": "end", + "query": query, + "returncode": returncode, + }, + ) + await handler.on_tool_end(output, run_id=run_id) + + await command("empty", "true", returncode=0) + await command("failed", "false", returncode=2, output="failed") + await command("unsafe", "dangerous", safe=False, output="Rejected") + return "Finished" + + hitl.run_agent = run_agent + app = UrsaTextualApp(hitl) + + async with app.run_test(size=(100, 36)) as pilot: + await pilot.press("r", "u", "n", "enter") + await pilot.pause() + await app.workers.wait_for_complete() + await pilot.pause() + cards = list(app.query(RunCommandCard)) + assert [card.completed for card in cards] == [True, True, True] + assert [card.returncode for card in cards] == [0, 2, None] + assert [card.safety_failed for card in cards] == [False, False, True] + assert [ + card.query_one(".command-compact-state", Static).content + for card in cards + ] == ["✓", "✗", "⚔️"] + assert all( + not card.query_one(".command-compact").has_class("hidden") + for card in cards + ) + assert cards[-1].query_one(CommandSafetyIndicator).status == "failed" diff --git a/tests/cli/tui/event_cards/test_plan.py b/tests/cli/tui/event_cards/test_plan.py new file mode 100644 index 00000000..9d090bf7 --- /dev/null +++ b/tests/cli/tui/event_cards/test_plan.py @@ -0,0 +1,224 @@ +import asyncio + +from textual.containers import VerticalScroll +from textual.widgets import Markdown, Static + +from tests.cli._app_fakes import FakeHITL, emit_event +from ursa.cli.tui.app import UrsaTextualApp +from ursa.cli.tui.event_cards import PlanCard +from ursa.cli.tui.turn import Turn + + +def plan_steps(count=7): + return [ + { + "name": f"Step {index}", + "description": ( + "The quick brown fox jumped over the detailed implementation " + "notes and continued all the way to the lazy river." + ), + } + for index in range(1, count + 1) + ] + + +async def test_plan_card_renders_drafting_and_collapsed_steps(tmp_path): + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test(size=(100, 36)) as pilot: + turn = Turn("make a plan", tmp_path) + await app.query_one("#conversation", VerticalScroll).mount(turn) + await turn.event({ + "agent": "PlanningAgent", + "stage": "generate", + "message": "Drafting plan", + }) + await pilot.pause() + plan = turn.query_one(PlanCard) + assert plan.state == "drafting" + assert plan.steps == [] + + await turn.event({ + "agent": "PlanningAgent", + "stage": "generate_result", + "message": "Drafted plan", + "steps": plan_steps(), + }) + await pilot.pause() + markdown = plan.query_one(Markdown) + source = str(markdown.source) + assert len(turn.query(PlanCard)) == 1 + assert plan.revision == 1 + assert plan.state == "reviewing" + assert len(plan.steps) == 7 + assert "1. Step 1" in source + assert "2. Step 2" in source + assert "… 3 middle steps hidden …" in source + assert "6. Step 6" in source + assert "7. Step 7" in source + assert "_… truncated …_" in source + hint = plan.query_one(".event-expand-hint", Static) + assert str(hint.content) == "Click to expand" + assert all( + node.region.height == 1 + for node in markdown.query("*") + if type(node).__name__ == "MarkdownListItem" + ) + + await pilot.resize_terminal(160, 36) + await pilot.pause() + wide_first_step = next( + line + for line in str(markdown.source).splitlines() + if "1. Step 1" in line + ) + assert "truncated" not in wide_first_step + assert "lazy river" in wide_first_step + + +async def test_plan_card_tracks_revisions_approval_and_expansion(tmp_path): + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test(size=(100, 36)) as pilot: + turn = Turn("make a plan", tmp_path) + await app.query_one("#conversation", VerticalScroll).mount(turn) + await turn.event({ + "agent": "PlanningAgent", + "stage": "generate_result", + "message": "Drafted plan", + "steps": plan_steps(), + }) + plan = turn.query_one(PlanCard) + await turn.event({ + "agent": "PlanningAgent", + "stage": "reflect_result", + "message": "Plan needs another pass", + "approved": False, + "reason": "Add a concrete validation step before implementation.", + }) + collapsed_source = str(plan.query_one(Markdown).source) + assert plan.state == "revision_needed" + assert plan.review_reason == ( + "Add a concrete validation step before implementation." + ) + assert "concrete validation step" not in collapsed_source + + await turn.event({ + "agent": "PlanningAgent", + "stage": "generate", + "message": "Drafting plan", + }) + await turn.event({ + "agent": "PlanningAgent", + "stage": "generate_result", + "message": "Revised plan", + "steps": plan_steps(4), + }) + revised = list(turn.query(PlanCard))[-1] + assert revised.revision == 2 + assert len(revised.steps) == 4 + assert revised.state == "reviewing" + await turn.event({ + "agent": "PlanningAgent", + "stage": "reflect", + "message": "Reviewing plan", + }) + assert revised.state == "reviewing" + await turn.event({ + "agent": "PlanningAgent", + "stage": "reflect_result", + "message": "Plan approved", + "approved": True, + }) + await pilot.pause() + + plans = list(turn.query(PlanCard)) + assert len(plans) == 2 + assert not plans[0].expanded + assert not plans[1].expanded + assert plans[1].state == "complete" + + await pilot.press("ctrl+o") + assert all(plan.expanded for plan in plans) + assert ( + str(plans[0].query_one(".event-expand-hint", Static).content) + == "Click to collapse" + ) + assert "middle steps hidden" not in str( + plans[0].query_one(Markdown).source + ) + expanded_source = str(plans[0].query_one(Markdown).source) + assert "**Revision feedback**" in expanded_source + assert "> Add a concrete validation step before implementation." in ( + expanded_source + ) + assert any( + type(node).__name__ == "MarkdownBlockQuote" + for node in plans[0].query_one(Markdown).query("*") + ) + + await pilot.press("ctrl+o") + assert all(not plan.expanded for plan in plans) + assert ( + str(plans[0].query_one(".event-expand-hint", Static).content) + == "Click to expand" + ) + + +async def test_agent_completion_stops_pending_plan_review_spinner(tmp_path): + hitl = FakeHITL(tmp_path) + + async def run_agent(_name, _prompt, callbacks=None): + handler = callbacks[0] + await emit_event( + handler, + { + "agent": "PlanningAgent", + "stage": "generate_result", + "message": "Drafted final plan", + "steps": [{"name": "Finish", "description": "Ship it"}], + }, + ) + return "Final plan" + + hitl.run_agent = run_agent + app = UrsaTextualApp(hitl) + + async with app.run_test(size=(100, 36)) as pilot: + await pilot.press("p", "l", "a", "n", "enter") + await app.workers.wait_for_complete() + await pilot.pause() + + plan = app.query_one(PlanCard) + assert plan.state == "complete" + frame = plan._frame + + await asyncio.sleep(0.7) + await pilot.pause() + assert plan._frame == frame + + +async def test_failed_agent_stops_drafting_plan_spinner(tmp_path): + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test(size=(100, 36)) as pilot: + turn = Turn("make a plan", tmp_path) + await app.query_one("#conversation", VerticalScroll).mount(turn) + await turn.event({ + "agent": "PlanningAgent", + "stage": "generate", + "message": "Drafting plan", + }) + plan = turn.query_one(PlanCard) + + turn.finish_activity(succeeded=False) + await pilot.pause() + + assert plan.state == "revision_needed" + assert "draft completed" in plan.review_reason + source = str(plan.query_one(Markdown).source) + assert "Plan drafting failed" in source + assert "Drafting Plan" not in source + await asyncio.sleep(0.7) + await pilot.pause() + assert str(plan.query_one(Markdown).source) == source diff --git a/tests/cli/tui/event_cards/test_specialized.py b/tests/cli/tui/event_cards/test_specialized.py new file mode 100644 index 00000000..d01273c9 --- /dev/null +++ b/tests/cli/tui/event_cards/test_specialized.py @@ -0,0 +1,147 @@ +from textual.containers import VerticalScroll +from textual.widgets import Static + +from tests.cli._app_fakes import FakeHITL +from ursa.cli.tui.app import UrsaTextualApp +from ursa.cli.tui.event_cards import ( + AgentEventCard, + ArtifactCard, + EditCard, + SearchEventCard, +) +from ursa.cli.tui.turn import Turn + + +async def test_multiple_edit_rows_expand_independently_under_one_heading( + tmp_path, +): + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test(size=(100, 60)) as pilot: + turn = Turn("edit two files", tmp_path) + await app.query_one("#conversation", VerticalScroll).mount(turn) + for event in ( + { + "tool": "edit_code", + "path": "one.py", + "old_code": "value = 1", + "new_code": "value = 2", + }, + { + "tool": "write_code", + "path": "two.py", + "code": "value = 2", + }, + ): + await turn.event(event) + await pilot.pause() + + first, second = turn.query(EditCard) + assert len(turn.query(".edit-group-title")) == 1 + assert "one.py" in str(first.query_one(".edit-title", Static).content) + assert "two.py" in str(second.query_one(".edit-title", Static).content) + first.scroll_visible(animate=False) + await pilot.pause() + assert await pilot.click(first.query_one(".edit-header")) + assert not first.query_one(".edit-diff").has_class("hidden") + assert second.query_one(".edit-diff").has_class("hidden") + expanded = first.query_one(".edit-diff", Static).content.code + assert "-value = 1" in expanded + assert "+value = 2" in expanded + + +async def test_specialized_agent_events_and_artifacts_update_live(tmp_path): + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test(size=(100, 36)) as pilot: + turn = Turn("investigate", tmp_path) + await app.query_one("#conversation", VerticalScroll).mount(turn) + await turn.event({ + "agent": "HypothesizerAgent", + "stage": "generate", + "message": "Generating hypotheses", + }) + await turn.event({ + "agent": "HypothesizerAgent", + "stage": "critique_result", + "message": "Critiqued hypotheses", + "preview": "The second hypothesis survives.", + }) + await turn.event({ + "agent": "HypothesizerAgent", + "stage": "finalize_result", + "message": "Finalized hypotheses", + "artifact": { + "content": "# Final hypothesis", + "mime_type": "text/markdown", + "metadata": {"title": "Hypothesis"}, + }, + }) + await pilot.pause() + + agent_cards = list(turn.query(AgentEventCard)) + assert len(agent_cards) == 1 + assert len(agent_cards[0].lines) == 3 + assert agent_cards[0].details == ["The second hypothesis survives."] + assert len(turn.query(ArtifactCard)) == 1 + + artifact = turn.query_one(ArtifactCard) + for card in turn.query(".event-card"): + assert ( + str(card.query_one(".event-expand-hint", Static).content) + == "Click to expand" + ) + artifact.mark_done() + + class Click: + stopped = False + + def stop(self): + self.stopped = True + + click = Click() + artifact.on_click(click) + assert click.stopped + assert artifact.done + assert artifact.expanded + assert ( + str(artifact.query_one(".event-expand-hint", Static).content) + == "Click to collapse" + ) + + +async def test_search_and_lammps_events_render_specialized_details(tmp_path): + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test(size=(100, 36)) as pilot: + turn = Turn("search then simulate", tmp_path) + await app.query_one("#conversation", VerticalScroll).mount(turn) + await turn.event({ + "tool": "run_web_search", + "stage": "search_result", + "phase": "end", + "message": "Web search complete", + "query": "ursa events", + "result_chars": 2048, + }) + await turn.event({ + "agent": "LammpsAgent", + "stage": "choose_potential", + "phase": "end", + "message": "Selected potential", + "potential_id": "Ni_u3.eam", + "chosen_index": 2, + "rationale": "Best match for nickel.", + "output_path": "runs/ni", + }) + await pilot.pause() + + search = turn.query_one(SearchEventCard) + assert len(search.lines) == 1 + assert "ursa events" in search.lines[0] + assert search.details == ["2,048 result characters"] + lammps = turn.query_one(AgentEventCard) + assert len(lammps.lines) == 1 + assert "Ni_u3.eam" in lammps.details[0] + assert "Best match for nickel." in lammps.details[0] + assert "Output: runs/ni" in lammps.details[0] diff --git a/tests/cli/tui/event_cards/test_tools.py b/tests/cli/tui/event_cards/test_tools.py new file mode 100644 index 00000000..d8bf87d1 --- /dev/null +++ b/tests/cli/tui/event_cards/test_tools.py @@ -0,0 +1,162 @@ +from langchain_core.messages import ToolMessage +from textual.containers import VerticalScroll +from textual.widgets import Markdown, Static + +from tests.cli._app_fakes import FakeHITL +from ursa.cli.tui.app import UrsaTextualApp +from ursa.cli.tui.event_cards import ToolCallCard +from ursa.cli.tui.event_handler import TextualEventHandler +from ursa.cli.tui.turn import Turn + + +async def test_default_tool_card_switches_from_input_to_output(tmp_path): + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test(size=(100, 36)) as pilot: + turn = Turn("call it", tmp_path) + await app.query_one("#conversation", VerticalScroll).mount(turn) + handler = TextualEventHandler(app, turn) + await handler.on_tool_start( + {"name": "lookup_widget"}, + "", + run_id="tool-one", + inputs={"query": "bear", "limit": 5}, + ) + await pilot.pause() + + card = turn.query_one(ToolCallCard) + assert ( + str(card.query_one(".tool-call-title", Static).content) + == "🛠️ lookup_widget" + ) + assert card.tool_input == {"query": "bear", "limit": 5} + assert not card.completed + assert "bear" in str( + card.query_one(".tool-call-preview", Static).content + ) + assert card.query_one(".tool-call-state", Static).content in ( + *card.app.query_one(".activity").FRAMES, + ) + assert card.query_one(".tool-call-details").has_class("hidden") + + card.set_expanded(True) + assert not card.query_one(".tool-call-details").has_class("hidden") + assert card.query_one(".tool-output-pane").has_class("hidden") + input_syntax = card.query_one(".tool-input-json", Static).content + assert type(input_syntax).__name__ == "Syntax" + assert input_syntax.code == '{\n "limit": 5,\n "query": "bear"\n}' + + await handler.on_tool_end( + {"matches": ["polar", "grizzly"]}, run_id="tool-one" + ) + await pilot.pause() + + assert card.completed + assert card.query_one(".tool-call-state", Static).content == "✓" + assert "polar" in str( + card.query_one(".tool-call-preview", Static).content + ) + assert not card.query_one(".tool-output-pane").has_class("hidden") + output_syntax = card.query_one(".tool-output-json", Static).content + assert type(output_syntax).__name__ == "Syntax" + assert '"grizzly"' in output_syntax.code + + +async def test_default_tool_card_shows_failure_output(tmp_path): + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test(size=(100, 24)) as pilot: + turn = Turn("call it", tmp_path) + await app.query_one("#conversation", VerticalScroll).mount(turn) + handler = TextualEventHandler(app, turn) + await handler.on_tool_start( + {"name": "lookup_widget"}, "", run_id="bad", inputs={} + ) + await handler.on_tool_error(ValueError("bad filter"), run_id="bad") + await pilot.pause() + + card = turn.query_one(ToolCallCard) + assert card.failed + assert card.output == "bad filter" + assert card.query_one(".tool-call-state", Static).content == "✗" + + +async def test_tool_call_preview_renders_code_brackets_as_plain_text(tmp_path): + app = UrsaTextualApp(FakeHITL(tmp_path)) + output = "[patch={'old_code': 'ax.set_xticklabels([labels])'}]" + + async with app.run_test(size=(100, 24)) as pilot: + turn = Turn("edit it", tmp_path) + await app.query_one("#conversation", VerticalScroll).mount(turn) + handler = TextualEventHandler(app, turn) + await handler.on_tool_start( + {"name": "edit_plot"}, "", run_id="code", inputs={} + ) + await handler.on_tool_end(output, run_id="code") + await pilot.pause() + + card = turn.query_one(ToolCallCard) + assert card.completed + assert card.output == output + + +async def test_tool_message_prefers_structured_content_as_json(tmp_path): + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test(size=(100, 24)) as pilot: + turn = Turn("call it", tmp_path) + await app.query_one("#conversation", VerticalScroll).mount(turn) + handler = TextualEventHandler(app, turn) + await handler.on_tool_start( + {"name": "lookup_widget"}, "", run_id="structured", inputs={} + ) + await handler.on_tool_end( + ToolMessage( + content="Human-readable fallback", + artifact={ + "structured_content": {"matches": ["polar", "grizzly"]} + }, + tool_call_id="structured", + ), + run_id="structured", + ) + await pilot.pause() + + card = turn.query_one(ToolCallCard) + card.set_expanded(True) + output = card.query_one(".tool-output-json", Static) + assert not output.has_class("hidden") + assert '"grizzly"' in output.content.code + assert card.query_one(".tool-output-markdown").has_class("hidden") + + +async def test_text_only_tool_message_renders_as_markdown(tmp_path): + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test(size=(100, 24)) as pilot: + turn = Turn("call it", tmp_path) + await app.query_one("#conversation", VerticalScroll).mount(turn) + handler = TextualEventHandler(app, turn) + await handler.on_tool_start( + {"name": "lookup_widget"}, "", run_id="text", inputs={} + ) + await handler.on_tool_end( + ToolMessage( + content=[ + { + "type": "text", + "text": "## Result\n\n**Found it.**", + } + ], + tool_call_id="text", + ), + run_id="text", + ) + await pilot.pause() + + card = turn.query_one(ToolCallCard) + card.set_expanded(True) + markdown = card.query_one(".tool-output-markdown", Markdown) + assert not markdown.has_class("hidden") + assert str(markdown.source) == "## Result\n\n**Found it.**" + assert card.query_one(".tool-output-json").has_class("hidden") diff --git a/tests/cli/tui/test_agent_info.py b/tests/cli/tui/test_agent_info.py new file mode 100644 index 00000000..c068721e --- /dev/null +++ b/tests/cli/tui/test_agent_info.py @@ -0,0 +1,152 @@ +import asyncio +import threading +import time +from types import SimpleNamespace + +from ursa.cli.tui.agent_info import load_agent_details, load_agent_tools + + +class ToolArgs: + @classmethod + def model_json_schema(cls): + return { + "properties": {"query": {"type": "string"}}, + "required": ["query"], + } + + +class Tool: + name = "search" + description = "Search configured sources." + args_schema = ToolArgs + return_direct = True + + +async def test_agent_details_preserve_runtime_order_without_loading_agents(): + wrappers = { + "execute": SimpleNamespace( + description="Execute work.", + config={"mode": "safe"}, + tool_sources={"search": "laboratory"}, + _agent=None, + ), + "chat": SimpleNamespace( + description="Answer questions.", + config={}, + tool_sources={}, + _agent=None, + ), + } + calls = [] + + async def get_agent(name): + calls.append(name) + wrapper = wrappers[name] + wrapper._agent = SimpleNamespace(tools={"search": Tool()}) + return wrapper + + hitl = SimpleNamespace(agents=wrappers, get_agent=get_agent) + + details = load_agent_details(hitl) + + assert [agent.name for agent in details] == ["execute", "chat"] + assert calls == [] + assert details[0].config == (("mode", "safe"),) + assert details[0].tools == () + assert not details[0].tools_loaded + + tools = await load_agent_tools(hitl, "execute") + + assert calls == ["execute"] + assert [tool.name for tool in tools] == ["search"] + assert tools[0].arguments[0].name == "query" + assert tools[0].mcp_server == "laboratory" + + +async def test_agent_details_expose_tools_from_initialized_agent(): + wrapper = SimpleNamespace( + description="Execute work.", + config={}, + tool_sources={}, + _agent=SimpleNamespace(tools={"search": Tool()}), + ) + calls = [] + + async def get_agent(name): + calls.append(name) + return wrapper + + details = load_agent_details( + SimpleNamespace(agents={"execute": wrapper}, get_agent=get_agent) + ) + + assert calls == [] + assert not details[0].tools_loaded + assert [tool.name for tool in details[0].tools] == ["search"] + assert details[0].tools[0].arguments == () + + tools = await load_agent_tools( + SimpleNamespace(get_agent=get_agent), "execute" + ) + assert calls == ["execute"] + assert [tool.name for tool in tools] == ["search"] + + +def test_initialized_agent_snapshot_does_not_generate_tool_schemas(): + class UnexpectedSchema: + @classmethod + def model_json_schema(cls): + raise AssertionError("schema generation must be deferred") + + tool = Tool() + tool.args_schema = UnexpectedSchema + wrapper = SimpleNamespace( + description="Initialized agent", + config={}, + tool_sources={}, + _agent=SimpleNamespace(tools={"search": tool}), + ) + + details = load_agent_details(SimpleNamespace(agents={"execute": wrapper})) + + assert not details[0].tools_loaded + assert [tool.name for tool in details[0].tools] == ["search"] + + +async def test_agent_tool_schema_conversion_runs_off_event_loop(): + event_loop_thread = threading.get_ident() + schema_threads = [] + + class SlowSchema: + @classmethod + def model_json_schema(cls): + schema_threads.append(threading.get_ident()) + time.sleep(0.05) + return {"properties": {}} + + tool = Tool() + tool.args_schema = SlowSchema + wrapper = SimpleNamespace( + _agent=SimpleNamespace(tools={"search": tool}), + tool_sources={}, + ) + + async def get_agent(_name): + return wrapper + + ticks = 0 + loading = True + + async def ticker(): + nonlocal ticks + while loading: + ticks += 1 + await asyncio.sleep(0.005) + + ticker_task = asyncio.create_task(ticker()) + await load_agent_tools(SimpleNamespace(get_agent=get_agent), "execute") + loading = False + await ticker_task + + assert schema_threads[0] != event_loop_thread + assert ticks > 1 diff --git a/tests/cli/tui/test_app.py b/tests/cli/tui/test_app.py new file mode 100644 index 00000000..f3a03638 --- /dev/null +++ b/tests/cli/tui/test_app.py @@ -0,0 +1,631 @@ +import asyncio +import io +from types import SimpleNamespace + +import pytest +from textual.containers import VerticalScroll +from textual.widgets import Markdown, Static + +import ursa.cli.tui.app as app_module +import ursa.util.crossplatform as crossplatform +from tests.cli._app_fakes import FakeHITL, emit_event +from ursa.cli.tui.app import UrsaTextualApp +from ursa.cli.tui.event_cards import EventCard, ExceptionCard, RunCommandCard +from ursa.cli.tui.turn import Turn +from ursa.cli.tui.widgets import ( + ActivityIndicator, + MessageCard, + PromptArea, + WelcomeBanner, +) + + +def test_copy_bindings_are_uniform_and_global(): + bindings = { + binding.key: binding + for binding in UrsaTextualApp._effective_bindings(UrsaTextualApp) + } + + assert bindings["ctrl+c"].action != "copy_text" + for key in ("ctrl+shift+c", "super+c"): + assert bindings[key].action == "copy_text" + assert bindings[key].priority + + +async def test_welcome_banner_starts_at_top_of_conversation(tmp_path): + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test(size=(100, 36)) as pilot: + await pilot.pause() + conversation = app.query_one("#conversation", VerticalScroll) + banner = app.query_one(WelcomeBanner) + + assert banner.region.y == conversation.content_region.y + + turn = Turn("short conversation", tmp_path) + await conversation.mount(turn) + await turn.add_response("Short response") + await pilot.pause() + + assert banner.region.y == conversation.content_region.y + + +async def test_prompt_submission_events_and_history(tmp_path): + hitl = FakeHITL(tmp_path) + + async def run_agent(name, prompt, callbacks=None): + hitl.calls.append((name, prompt)) + handler = callbacks[0] + await emit_event( + handler, + tool="read_file", + stage="read", + message="Reading file", + path="src/example.py", + ) + await handler.on_llm_end( + SimpleNamespace( + llm_output={ + "token_usage": { + "prompt_tokens": 30, + "completion_tokens": 7, + "total_tokens": 37, + "prompt_tokens_details": {"cached_tokens": 12}, + } + } + ) + ) + return "**Finished**" + + hitl.run_agent = run_agent + app = UrsaTextualApp(hitl) + + async with app.run_test(size=(100, 36)) as pilot: + await pilot.press("h", "e", "l", "l", "o", "enter") + await pilot.pause() + + assert hitl.calls == [("chat", "hello")] + messages = list(app.query(MessageCard)) + assert len(messages) == 2 + assert messages[0].styles.background != messages[1].styles.background + assert messages[1].content == "**Finished**" + assert len(app.query(EventCard)) == 1 + assert app.total_tokens == 37 + assert app.input_tokens == 30 + assert app.output_tokens == 7 + assert app.cached_tokens == 12 + status = str(app.query_one("#status", Static).content) + assert "37 tokens" in status + assert "input" not in status + assert "output" not in status + assert "cached" not in status + turn = app.query_one(Turn) + assert turn.token_usage == 37 + assert isinstance(list(turn.children)[-3], ActivityIndicator) + assert isinstance(list(turn.children)[-2], MessageCard) + assert list(turn.children)[-1].has_class("turn-end-marker") + roles = list(app.query(".message-role")) + assert len(roles) == 1 + assert str(roles[0].content) == "URSA" + for message in app.query(MessageCard): + markdown = message.query_one(Markdown) + assert list(markdown.children)[-1].styles.margin.bottom == 0 + + prompt = app.query_one(PromptArea) + await pilot.press("up") + assert prompt.text == "hello" + + +async def test_agent_exception_card_expands_to_full_traceback(tmp_path): + hitl = FakeHITL(tmp_path) + + async def run_agent(_name, _prompt, callbacks=None): + raise RuntimeError("provider disconnected") + + hitl.run_agent = run_agent + app = UrsaTextualApp(hitl) + + async with app.run_test(size=(100, 36)) as pilot: + await pilot.press("h", "e", "l", "l", "o", "enter") + await app.workers.wait_for_complete() + await pilot.pause() + + card = app.query_one(ExceptionCard) + assert card.lines == ["RuntimeError: provider disconnected"] + assert len(card.details) == 1 + assert "Traceback (most recent call last):" in card.details[0] + assert "in run_agent" in card.details[0] + assert 'raise RuntimeError("provider disconnected")' in card.details[0] + assert card.details[0].endswith("RuntimeError: provider disconnected\n") + assert not card.expanded + + class Click: + def stop(self): + pass + + card.on_click(Click()) + await pilot.pause() + assert card.expanded + rich_traceback = card.query_one(".exception-traceback", Static) + assert not rich_traceback.has_class("hidden") + assert type(rich_traceback.content).__name__ == "Traceback" + assert rich_traceback.content.trace.stacks[-1].exc_value == ( + "provider disconnected" + ) + + +async def test_command_events_from_a_worker_thread_update_the_ui(tmp_path): + hitl = FakeHITL(tmp_path) + + async def run_agent(_name, _prompt, callbacks=None): + handler = callbacks[0] + + def emit_command_event(): + asyncio.run( + emit_event( + handler, + { + "tool": "run_command", + "stage": "execute", + "phase": "start", + "message": "Running command", + "query": "pwd", + }, + ) + ) + + await asyncio.to_thread(emit_command_event) + return "Command finished" + + hitl.run_agent = run_agent + app = UrsaTextualApp(hitl) + + async with app.run_test(size=(100, 36)) as pilot: + await pilot.press("r", "u", "n", "enter") + await pilot.pause() + await app.workers.wait_for_complete() + await pilot.pause() + card = app.query_one(RunCommandCard) + assert card.command == "pwd" + assert len(app.query(MessageCard)) == 2 + + +async def test_turn_spinner_animates_and_shows_reasoning_while_agent_runs( + tmp_path, +): + hitl = FakeHITL(tmp_path) + release_agent = asyncio.Event() + + async def run_agent(_name, _prompt, callbacks=None): + await callbacks[0].on_llm_new_token( + "", + chunk=SimpleNamespace( + message=SimpleNamespace( + additional_kwargs={ + "reasoning_content": "Inspecting the request" + } + ) + ), + ) + await release_agent.wait() + return "Finished" + + hitl.run_agent = run_agent + app = UrsaTextualApp(hitl) + + async with app.run_test(size=(100, 36)) as pilot: + await pilot.press("w", "a", "i", "t", "enter") + await pilot.pause() + activity = app.query_one(ActivityIndicator) + spinner = activity.query_one(".activity-spinner", Static) + label = activity.query_one(".activity-text", Static) + done_mark = activity.query_one(".activity-done-mark", Static) + first_frame = str(spinner.content) + assert first_frame in ActivityIndicator.FRAMES + assert str(label.content) == "Inspecting the request" + + await asyncio.sleep(0.1) + await pilot.pause() + assert str(spinner.content) in ActivityIndicator.FRAMES + assert str(spinner.content) != first_frame + + release_agent.set() + await pilot.pause() + assert str(spinner.content) == "" + assert str(label.content) == "" + assert str(done_mark.content) == "" + assert activity.has_class("hidden") + assert not activity.has_class("done") + + activity.finish(elapsed=31, tokens=1234) + assert str(label.content) == "Done in 31s and 1,234 tokens" + assert str(done_mark.content) + assert not activity.has_class("hidden") + assert activity.has_class("done") + assert label.styles.content_align == ("right", "middle") + activity.finish(elapsed=30, tokens=1234) + assert str(label.content) == "" + assert str(done_mark.content) == "" + assert activity.has_class("hidden") + + +async def test_ctrl_c_reports_that_running_agent_cannot_be_cancelled( + tmp_path, monkeypatch +): + hitl = FakeHITL(tmp_path) + started = asyncio.Event() + release = asyncio.Event() + + async def run_agent(_name, _prompt, callbacks=None): + started.set() + await release.wait() + return "Finished" + + hitl.run_agent = run_agent + app = UrsaTextualApp(hitl) + notifications = [] + monkeypatch.setattr( + app, + "notify", + lambda message, **kwargs: notifications.append((message, kwargs)), + ) + + async with app.run_test(size=(100, 36)) as pilot: + await pilot.press("w", "a", "i", "t", "enter") + await started.wait() + prompt = app.query_one(PromptArea) + assert prompt.disabled + + await pilot.press("ctrl+c") + await pilot.pause() + + assert prompt.disabled + assert any(worker.group == "agent" for worker in app.workers) + assert len(notifications) == 1 + assert "not supported" in notifications[0][0] + assert "Ctrl+D" in notifications[0][0] + assert notifications[0][1]["severity"] == "warning" + + release.set() + await app.workers.wait_for_complete() + assert not prompt.disabled + + +async def test_clear_conversation_is_refused_during_active_turn( + tmp_path, monkeypatch +): + hitl = FakeHITL(tmp_path) + started = asyncio.Event() + release = asyncio.Event() + + async def run_agent(_name, _prompt, callbacks=None): + started.set() + await release.wait() + return "Finished" + + hitl.run_agent = run_agent + app = UrsaTextualApp(hitl) + notifications = [] + monkeypatch.setattr( + app, + "notify", + lambda message, **kwargs: notifications.append((message, kwargs)), + ) + + async with app.run_test(size=(100, 36)) as pilot: + await pilot.press("w", "a", "i", "t", "enter") + await started.wait() + turn = app.query_one(Turn) + + await pilot.press("ctrl+l") + await pilot.pause() + + assert turn.is_mounted + assert "not allowed" in notifications[0][0] + assert "Ctrl+D" in notifications[0][0] + release.set() + await pilot.pause() + + +async def test_quitting_waits_for_active_agent_then_exits( + tmp_path, monkeypatch +): + hitl = FakeHITL(tmp_path) + started = asyncio.Event() + release = asyncio.Event() + finished = asyncio.Event() + + async def run_agent(_name, _prompt, callbacks=None): + started.set() + await release.wait() + finished.set() + return "Finished" + + hitl.run_agent = run_agent + app = UrsaTextualApp(hitl) + notifications = [] + monkeypatch.setattr( + app, + "notify", + lambda message, **kwargs: notifications.append((message, kwargs)), + ) + + async with app.run_test(size=(100, 36)) as pilot: + await pilot.press("w", "a", "i", "t", "enter") + await started.wait() + await pilot.press("ctrl+q") + await pilot.pause() + + assert not app._exit + assert notifications + assert "active turn finishes" in notifications[0][0] + assert "Ctrl+D" in notifications[0][0] + + release.set() + await finished.wait() + await pilot.pause() + + assert app._exit + + +async def test_command_arrows_navigate_turn_markers_and_end_anchor(tmp_path): + hitl = FakeHITL(tmp_path) + app = UrsaTextualApp(hitl) + + async with app.run_test(size=(100, 36)) as pilot: + await pilot.press("o", "n", "e", "enter") + await pilot.pause() + await pilot.press("t", "w", "o", "enter") + await pilot.pause() + + markers = app._turn_markers() + assert len(markers) == 8 + assert [ + marker.role + if isinstance(marker, MessageCard) + else "end" + if marker.has_class("turn-end-marker") + else "activity" + for marker in markers + ] == [ + "user", + "activity", + "assistant", + "end", + "user", + "activity", + "assistant", + "end", + ] + + await pilot.press("alt+down") + assert app._turn_navigation_marker is markers[7] + await pilot.press("alt+up") + assert app._turn_navigation_marker is markers[6] + await pilot.press("alt+up") + assert app._turn_navigation_marker is markers[5] + await pilot.press("alt+up") + assert app._turn_navigation_marker is markers[4] + await pilot.press("alt+down") + assert app._turn_navigation_marker is markers[5] + + +async def test_turn_navigation_changes_real_scroll_position(tmp_path): + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test(size=(80, 20)) as pilot: + for index in range(6): + await pilot.press(*str(index), "enter") + await pilot.pause() + conversation = app.query_one("#conversation", VerticalScroll) + bottom = conversation.scroll_y + assert bottom > 0 + + await pilot.press("alt+down") + await pilot.pause() + assert app._turn_navigation_marker is app._turn_markers()[-1] + assert conversation.scroll_y == conversation.max_scroll_y + assert conversation.is_anchored + + # Several markers from the latest turns may already be visible at the + # maximum scroll offset. Cross into an earlier turn before asserting + # that the viewport moved. + await pilot.press(*(["alt+up"] * 20)) + await pilot.pause() + assert conversation.scroll_y < bottom + + +async def test_new_cards_follow_bottom_without_moving_scrolled_view(tmp_path): + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test(size=(80, 20)) as pilot: + conversation = app.query_one("#conversation", VerticalScroll) + turn = Turn("test", tmp_path) + await conversation.mount(turn) + + for index in range(12): + await app.add_turn_event( + turn, + { + "type": "custom", + "tool": f"tool-{index}", + "phase": "start", + }, + ) + await pilot.pause() + + assert conversation.scroll_y == conversation.max_scroll_y + + conversation.scroll_to( + y=max(0, conversation.scroll_y - 3), + animate=False, + immediate=True, + ) + await pilot.pause() + scrolled_position = conversation.scroll_y + assert scrolled_position < conversation.max_scroll_y + + await app.add_turn_event( + turn, + { + "type": "custom", + "tool": "one-more-tool", + "phase": "start", + }, + ) + await pilot.pause() + + assert conversation.scroll_y == pytest.approx(scrolled_position) + + +async def test_user_scroll_cancels_initial_anchor_transition(tmp_path): + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test(size=(80, 20)) as pilot: + conversation = app.query_one("#conversation", VerticalScroll) + turn = Turn("test", tmp_path) + await conversation.mount(turn) + + for index in range(12): + await app.add_turn_event( + turn, + { + "type": "custom", + "tool": f"tool-{index}", + "phase": "start", + }, + ) + await pilot.pause(0.01) + if app._conversation_anchor_transition: + break + + assert app._conversation_anchor_transition + conversation.scroll_home(animate=False, immediate=True) + await pilot.pause(0.2) + + assert not conversation.is_anchored + scrolled_position = conversation.scroll_y + await app.add_turn_event( + turn, + { + "type": "custom", + "tool": "after-interruption", + "phase": "start", + }, + ) + await pilot.pause() + + assert conversation.scroll_y == pytest.approx(scrolled_position) + assert conversation.scroll_y < conversation.max_scroll_y + + await app.submit_prompt(PromptArea.Submitted("next prompt")) + await app.workers.wait_for_complete() + for _ in range(50): + await pilot.pause(0.02) + if conversation.is_anchored: + break + + assert conversation.is_anchored + assert conversation.scroll_y == conversation.max_scroll_y + + +@pytest.mark.parametrize( + ("prompt", "expected"), + [ + ("#plan\tinspect this", ("plan", "inspect this")), + ("#plan\ninspect this\ncarefully", ("plan", "inspect this\ncarefully")), + ("#plan", ("plan", "")), + ("#missing inspect this", ("chat", "#missing inspect this")), + ], +) +def test_hash_agent_routing_accepts_whitespace_and_multiline_prompts( + tmp_path, prompt, expected +): + app = UrsaTextualApp(FakeHITL(tmp_path)) + + assert app._route_prompt(prompt) == expected + + +def test_copy_to_clipboard_prefers_platform_tool_when_available( + tmp_path, monkeypatch +): + app = UrsaTextualApp(FakeHITL(tmp_path)) + fallback = [] + monkeypatch.setattr(crossplatform, "copy_to_clipboard", lambda text: True) + monkeypatch.setattr( + app_module.App, + "copy_to_clipboard", + lambda self, text: fallback.append(text), + ) + + app.copy_to_clipboard("hello") + + assert fallback == [] + assert app.clipboard == "hello" + + +def test_copy_to_clipboard_uses_osc52_when_platform_copy_unavailable( + tmp_path, monkeypatch +): + app = UrsaTextualApp(FakeHITL(tmp_path)) + fallback = [] + monkeypatch.setattr(crossplatform, "copy_to_clipboard", lambda text: False) + monkeypatch.setattr( + app_module.App, + "copy_to_clipboard", + lambda self, text: fallback.append(text), + ) + + app.copy_to_clipboard("hello") + + assert fallback == ["hello"] + assert app.clipboard == "hello" + + +def test_one_shot_routes_hash_agent_and_writes_response(tmp_path): + class OneShotHITL(FakeHITL): + async def run_agent(self, name, prompt, callbacks=None): + self.calls.append((name, prompt)) + return "One-shot result" + + hitl = OneShotHITL(tmp_path) + output = io.StringIO() + + result = app_module.run_textual_once( + hitl, "#plan inspect this", stdout=output + ) + + assert result == "One-shot result" + assert hitl.calls == [("plan", "inspect this")] + assert hitl.closed + assert "One-shot result" in output.getvalue() + + +async def test_user_scroll_during_anchor_start_gap_is_not_overridden(tmp_path): + # The anchor transition must start its animation synchronously with + # its flag; a user scroll in the gap before a deferred start could + # not stop an animation that had not begun, and the late start then + # drove the viewport away from the user's position. + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test(size=(80, 20)) as pilot: + conversation = app.query_one("#conversation", VerticalScroll) + turn = Turn("test", tmp_path) + await conversation.mount(turn) + + for index in range(12): + await app.add_turn_event( + turn, + {"type": "custom", "tool": f"tool-{index}", "phase": "start"}, + ) + await pilot.pause(0.01) + if app._conversation_anchor_transition: + break + + assert app._conversation_anchor_transition + assert app.animator.is_being_animated(conversation, "scroll_y") + + conversation.scroll_home(animate=False, immediate=True) + for _ in range(8): + await asyncio.sleep(0.03) + assert conversation.scroll_y == 0 diff --git a/tests/cli/tui/test_helpers.py b/tests/cli/tui/test_helpers.py new file mode 100644 index 00000000..cd5d6815 --- /dev/null +++ b/tests/cli/tui/test_helpers.py @@ -0,0 +1,79 @@ +from dataclasses import dataclass + +import pytest + +from ursa.cli.tui.helpers import ( + _fuzzy_match, + _plan_step_text, + _token_usage, + _token_usage_breakdown, + _truncate_middle, +) + + +def test_fuzzy_match_and_token_usage_support_common_shapes(): + assert _fuzzy_match("sre", "src/example.py") + assert not _fuzzy_match("xyz", "src/example.py") + assert _token_usage({"usage": {"total_tokens": 42}}) == 42 + + usage = _token_usage_breakdown({ + "usage_metadata": { + "input_tokens": 31, + "output_tokens": 11, + "total_tokens": 42, + "input_token_details": {"cache_read": 17}, + } + }) + assert ( + usage.input_tokens, + usage.output_tokens, + usage.cached_tokens, + usage.total_tokens, + ) == (31, 11, 17, 42) + + +@dataclass +class PlanStep: + name: str + description: str + + def model_dump(self): + return {"name": self.name, "description": self.description} + + +@pytest.mark.parametrize( + ("step", "expected"), + [ + ( + {"name": "Inspect", "description": " read\nfiles "}, + "2. Inspect: read files", + ), + ({"description": "Validate"}, "2. Step 2: Validate"), + (PlanStep("Ship", "run tests"), "2. Ship: run tests"), + ("Fallback", "2. Fallback"), + ], +) +def test_plan_step_text_normalizes_supported_step_shapes(step, expected): + assert _plan_step_text(2, step) == expected + + +def test_truncate_middle_preserves_short_text_and_both_ends(): + assert _truncate_middle("short", 20) == "short" + + result = _truncate_middle("alpha beta gamma delta epsilon", 24) + + assert result.startswith("alpha") + assert result.endswith("ilon") + assert "truncated" in result + + +@pytest.mark.parametrize( + ("width", "expected"), + [(0, ""), (1, "…"), (5, "alph…")], +) +def test_truncate_middle_falls_back_to_end_truncation(width, expected): + assert _truncate_middle("alpha beta gamma", width) == expected + + +def test_truncate_middle_uses_marker_when_it_just_fits(): + assert "truncated" in _truncate_middle("alpha beta gamma", 15) diff --git a/tests/cli/tui/test_turn.py b/tests/cli/tui/test_turn.py new file mode 100644 index 00000000..b4fbbcaf --- /dev/null +++ b/tests/cli/tui/test_turn.py @@ -0,0 +1,635 @@ +import asyncio +from pathlib import Path + +import pytest +from textual.containers import VerticalScroll +from textual.widgets import Static + +import ursa.cli.tui.event_handler as event_handler_module +import ursa.cli.tui.turn as turn_module +from tests.cli._app_fakes import FakeHITL, emit_event +from ursa.cli.tui.app import UrsaTextualApp +from ursa.cli.tui.event_cards import EditCard, FileActivityCard, RunCommandCard +from ursa.cli.tui.event_handler import TextualEventHandler +from ursa.cli.tui.turn import Turn +from ursa.cli.tui.widgets import ActivityIndicator, MessageCard +from ursa.util.events import DEFAULT_EVENT_NAME + + +async def test_turn_spacing_is_one_row_with_or_without_events(tmp_path): + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test(size=(100, 40)) as pilot: + conversation = app.query_one("#conversation", VerticalScroll) + + thinking = Turn("think about it", tmp_path) + await conversation.mount(thinking) + await pilot.pause() + thinking_user = thinking.query_one(MessageCard) + activity = thinking.query_one(ActivityIndicator) + assert activity.region.y - thinking_user.region.bottom == 1 + + no_tools = Turn("answer directly", tmp_path) + await conversation.mount(no_tools) + await no_tools.add_response("Direct answer") + no_tools.finish_activity() + + with_tool = Turn("run a command", tmp_path) + await conversation.mount(with_tool) + await with_tool.event({ + "tool": "run_command", + "phase": "start", + "query": "pwd", + "_command_id": "pwd", + }) + await with_tool.event({ + "tool": "run_command", + "phase": "end", + "query": "pwd", + "_command_id": "pwd", + "result": str(tmp_path), + "returncode": 0, + }) + await with_tool.add_response("Command answer") + with_tool.finish_activity() + await pilot.pause() + + direct_messages = list(no_tools.query(MessageCard)) + assert ( + direct_messages[1].region.y - direct_messages[0].region.bottom == 1 + ) + + tool_messages = list(with_tool.query(MessageCard)) + command = with_tool.query_one(RunCommandCard) + assert command.region.y - tool_messages[0].region.bottom == 1 + assert tool_messages[1].region.y - command.region.bottom == 1 + + +async def test_files_are_grouped_by_read_write_and_edit_operations(tmp_path): + hitl = FakeHITL(tmp_path) + + async def run_agent(_name, _prompt, callbacks=None): + handler = callbacks[0] + events = [ + {"tool": "read_file", "path": "src/read.py", "message": "Reading"}, + { + "tool": "read_file", + "path": "src/other.py", + "message": "Reading", + }, + { + "tool": "write_code", + "path": "src/new.py", + "code": "new file\n", + "message": "Writing", + }, + { + "tool": "edit_code", + "path": "src/edit.py", + "old_code": "old\n", + "new_code": "new\nmore\n", + "message": "Editing", + }, + { + "tool": "edit_code", + "path": str(tmp_path / "src" / "edit.py"), + "message": "Editing", + }, + ] + for event in events: + await handler.on_custom_event(DEFAULT_EVENT_NAME, event) + return "Finished" + + hitl.run_agent = run_agent + app = UrsaTextualApp(hitl) + + async with app.run_test(size=(100, 36)) as pilot: + await pilot.press("g", "o", "enter") + await app.workers.wait_for_complete() + await pilot.pause() + file_groups = list(app.query(FileActivityCard)) + assert len(file_groups) == 1 + reading = file_groups[0] + assert reading.files == { + "Reading": { + str(Path("src/read.py")): (None, None), + str(Path("src/other.py")): (None, None), + }, + "Editing": {}, + } + reading_summary = reading.query_one(".file-summary", Static).content + assert all( + path in reading_summary.plain + for path in (str(Path("src/read.py")), str(Path("src/other.py"))) + ) + writes = list(app.query(EditCard)) + assert [edit.path for edit in writes] == [ + str(Path("src/new.py")), + str(Path("src/edit.py")), + ] + assert [(edit.additions, edit.deletions) for edit in writes] == [ + (1, 0), + (2, 1), + ] + + +async def test_event_summary_groups_follow_activity_order(tmp_path): + hitl = FakeHITL(tmp_path) + + async def run_agent(_name, _prompt, callbacks=None): + handler = callbacks[0] + for path in ("fileA", "fileB", "fileC"): + await emit_event( + handler, + {"tool": "read_file", "path": path, "message": "Reading"}, + ) + await emit_event( + handler, + { + "tool": "edit_code", + "path": "fileB", + "message": "Editing", + "additions": 2, + "deletions": 1, + }, + ) + await handler.on_tool_start( + {"name": "run_command"}, + "", + run_id="ordered-command", + inputs={"query": "pwd"}, + ) + await handler.on_tool_end("", run_id="ordered-command") + await emit_event( + handler, + {"tool": "read_file", "path": "fileA", "message": "Reading"}, + ) + return "Finished" + + hitl.run_agent = run_agent + app = UrsaTextualApp(hitl) + + async with app.run_test(size=(100, 36)) as pilot: + await pilot.press("g", "o", "enter") + await pilot.pause() + events = list(app.query_one(Turn).query_one(".events").children) + assert [type(event) for event in events] == [ + FileActivityCard, + FileActivityCard, + RunCommandCard, + ] + assert list(events[0].files["Reading"]) == [ + "fileA", + "fileB", + "fileC", + ] + assert list(events[1].files["Editing"]) == ["fileB"] + + +async def test_activity_kinds_keep_independent_cards_open( + tmp_path, monkeypatch +): + clock = [100.0] + monkeypatch.setattr(turn_module, "monotonic", lambda: clock[0]) + monkeypatch.setattr(event_handler_module, "monotonic", lambda: clock[0]) + monkeypatch.setattr( + turn_module, + "SUMMARY_GROUP_GRACE_SECONDS", + 1.0, + ) + timers = [] + + class CapturedTimer: + def __init__(self, delay, callback): + self.delay = delay + self.callback = callback + self.stopped = False + + def stop(self): + self.stopped = True + + def fire(self): + assert not self.stopped + self.callback() + + def capture_timer(_turn, delay, callback): + timer = CapturedTimer(delay, callback) + timers.append(timer) + return timer + + monkeypatch.setattr(Turn, "set_timer", capture_timer) + hitl = FakeHITL(tmp_path) + reading_started = asyncio.Event() + switch_to_editing = asyncio.Event() + editing_started = asyncio.Event() + return_to_reading = asyncio.Event() + reading_updated = asyncio.Event() + finish_agent = asyncio.Event() + + async def run_agent(_name, _prompt, callbacks=None): + handler = callbacks[0] + await emit_event( + handler, + {"tool": "read_file", "path": "fileA", "message": "Reading"}, + ) + reading_started.set() + await switch_to_editing.wait() + await emit_event( + handler, + { + "tool": "edit_code", + "path": "fileB", + "message": "Editing", + "additions": 2, + "deletions": 1, + }, + ) + editing_started.set() + await return_to_reading.wait() + await emit_event( + handler, + {"tool": "read_file", "path": "fileC", "message": "Reading"}, + ) + reading_updated.set() + await finish_agent.wait() + return "Finished" + + hitl.run_agent = run_agent + app = UrsaTextualApp(hitl) + + async with app.run_test(size=(100, 36)) as pilot: + await pilot.press("g", "o", "enter") + await reading_started.wait() + await pilot.pause() + reading = list(app.query(FileActivityCard))[0] + + switch_to_editing.set() + await editing_started.wait() + await pilot.pause() + groups = list(app.query(FileActivityCard)) + assert len(groups) == 2 + editing = groups[1] + assert not reading.done + assert not editing.done + + return_to_reading.set() + await reading_updated.wait() + await pilot.pause() + assert list(reading.files["Reading"]) == ["fileA", "fileC"] + assert len(app.query(FileActivityCard)) == 2 + assert not reading.done + assert not editing.done + + old_reading_timer, editing_timer, current_reading_timer = timers + assert old_reading_timer.stopped + editing_timer.fire() + await pilot.pause() + assert not reading.done + assert editing.done + + current_reading_timer.fire() + await pilot.pause() + assert reading.done + + finish_agent.set() + await app.workers.wait_for_complete() + await pilot.pause() + + +async def test_event_group_grace_period_resets_after_each_update( + tmp_path, monkeypatch +): + clock = [100.0] + monkeypatch.setattr(turn_module, "monotonic", lambda: clock[0]) + monkeypatch.setattr(event_handler_module, "monotonic", lambda: clock[0]) + hitl = FakeHITL(tmp_path) + + async def run_agent(_name, _prompt, callbacks=None): + handler = callbacks[0] + await emit_event( + handler, + {"tool": "read_file", "path": "fileA", "message": "Reading"}, + ) + clock[0] += 2.0 + await emit_event( + handler, + {"tool": "read_file", "path": "fileB", "message": "Reading"}, + ) + # This is after the original deadline but before the deadline renewed + # by fileB. A failure to reset the grace period creates a new card. + clock[0] += 1.5 + await emit_event( + handler, + {"tool": "read_file", "path": "fileC", "message": "Reading"}, + ) + clock[0] += 3.001 + await emit_event( + handler, + {"tool": "read_file", "path": "fileD", "message": "Reading"}, + ) + return "Finished" + + hitl.run_agent = run_agent + app = UrsaTextualApp(hitl) + + async with app.run_test(size=(100, 36)) as pilot: + await pilot.press("g", "o", "enter") + await pilot.pause() + groups = list(app.query(FileActivityCard)) + assert len(groups) == 2 + assert list(groups[0].files["Reading"]) == [ + "fileA", + "fileB", + "fileC", + ] + assert list(groups[1].files["Reading"]) == ["fileD"] + + +async def test_summary_card_mounts_immediately_updates_and_finalizes_after_idle( + tmp_path, + monkeypatch, +): + clock = [100.0] + monkeypatch.setattr(turn_module, "monotonic", lambda: clock[0]) + monkeypatch.setattr(event_handler_module, "monotonic", lambda: clock[0]) + monkeypatch.setattr( + turn_module, + "SUMMARY_GROUP_GRACE_SECONDS", + 0.5, + ) + timers = [] + + class CapturedTimer: + def __init__(self, delay, callback): + self.delay = delay + self.callback = callback + self.stopped = False + + def stop(self): + self.stopped = True + + def fire(self): + assert not self.stopped + self.callback() + + def capture_timer(_turn, delay, callback): + timer = CapturedTimer(delay, callback) + timers.append(timer) + return timer + + monkeypatch.setattr(Turn, "set_timer", capture_timer) + hitl = FakeHITL(tmp_path) + first_emitted = asyncio.Event() + emit_second = asyncio.Event() + second_emitted = asyncio.Event() + emit_third = asyncio.Event() + third_emitted = asyncio.Event() + finish_agent = asyncio.Event() + + async def run_agent(_name, _prompt, callbacks=None): + handler = callbacks[0] + await emit_event( + handler, + {"tool": "read_file", "path": "fileA", "message": "Reading"}, + ) + first_emitted.set() + await emit_second.wait() + await emit_event( + handler, + {"tool": "read_file", "path": "fileB", "message": "Reading"}, + ) + second_emitted.set() + await emit_third.wait() + await emit_event( + handler, + {"tool": "read_file", "path": "fileC", "message": "Reading"}, + ) + third_emitted.set() + await finish_agent.wait() + return "Finished" + + hitl.run_agent = run_agent + app = UrsaTextualApp(hitl) + + async with app.run_test(size=(100, 36)) as pilot: + await pilot.press("g", "o", "enter") + await first_emitted.wait() + await pilot.pause() + + groups = list(app.query(FileActivityCard)) + assert len(groups) == 1 + first = groups[0] + assert list(first.files["Reading"]) == ["fileA"] + assert not first.done + + emit_second.set() + await second_emitted.wait() + await pilot.pause() + assert list(first.files["Reading"]) == ["fileA", "fileB"] + assert not first.done + assert timers[0].stopped + assert timers[1].delay == 0.5 + + timers[1].fire() + await pilot.pause() + assert first.done + + emit_third.set() + await third_emitted.wait() + await pilot.pause() + groups = list(app.query(FileActivityCard)) + assert len(groups) == 2 + assert groups[0] is first + assert list(groups[1].files["Reading"]) == ["fileC"] + assert not groups[1].done + + finish_agent.set() + await app.workers.wait_for_complete() + await pilot.pause() + assert groups[1].done + + +async def test_stale_summary_timer_cannot_finalize_replacement_card(tmp_path): + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test(size=(100, 36)): + turn = Turn("read", tmp_path) + await app.query_one("#conversation", VerticalScroll).mount(turn) + old = FileActivityCard("files:old") + replacement = FileActivityCard("files:new") + await turn.query_one(".events").mount(old, replacement) + turn._summary_cards["files:Reading"] = replacement + + turn._finalize_summary("files:Reading", old) + + assert not replacement.done + assert turn._summary_cards["files:Reading"] is replacement + + +async def test_parallel_read_callbacks_render_as_one_group(tmp_path): + hitl = FakeHITL(tmp_path) + + async def run_agent(_name, _prompt, callbacks=None): + handler = callbacks[0] + + async def read(run_id, path): + await handler.on_tool_start( + {"name": "read_file"}, + "", + run_id=run_id, + inputs={"filename": path}, + ) + await emit_event( + handler, + { + "tool": "read_file", + "phase": "start", + "path": path, + "message": "Reading file", + }, + ) + + await asyncio.gather( + read("read-a", "README.md"), + read("read-b", "tests/cli/test_config.py"), + read("read-c", "pyproject.toml"), + ) + await handler.on_tool_start( + {"name": "run_command"}, + "", + run_id="command-after-reads", + inputs={"query": "pwd"}, + ) + await handler.on_tool_end("output", run_id="command-after-reads") + # These delayed completions belong to the original Reading group and + # must not create another Reading group after the command. + for path in ( + "README.md", + "tests/cli/test_config.py", + "pyproject.toml", + ): + await emit_event( + handler, + { + "tool": "read_file", + "phase": "end", + "path": path, + "message": "File read", + }, + ) + await asyncio.gather( + handler.on_tool_end("a", run_id="read-a"), + handler.on_tool_end("b", run_id="read-b"), + handler.on_tool_end("c", run_id="read-c"), + ) + return "Finished" + + hitl.run_agent = run_agent + app = UrsaTextualApp(hitl) + + async with app.run_test(size=(100, 36)) as pilot: + await pilot.press("r", "e", "a", "d", "enter") + await app.workers.wait_for_complete() + await pilot.pause() + groups = list(app.query(FileActivityCard)) + assert len(groups) == 1 + events = list(app.query_one(Turn).query_one(".events").children) + assert [type(event) for event in events] == [ + FileActivityCard, + RunCommandCard, + ] + assert list(groups[0].files["Reading"]) == [ + "README.md", + str(Path("tests/cli/test_config.py")), + "pyproject.toml", + ] + + +async def test_file_failure_is_retained_on_its_activity_card(tmp_path): + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test(size=(100, 36)) as pilot: + turn = Turn("read it", tmp_path) + await app.query_one("#conversation", VerticalScroll).mount(turn) + handler = TextualEventHandler(app, turn) + await handler.on_tool_start( + {"name": "read_file"}, + "", + run_id="broken-read", + inputs={"filename": "broken.txt"}, + ) + await handler.on_tool_error( + PermissionError("permission denied"), run_id="broken-read" + ) + await pilot.pause() + + card = turn.query_one(FileActivityCard) + assert card.outcomes[("Reading", "broken.txt")] == ( + "failed", + "permission denied", + ) + assert "permission denied" in str( + card.query_one(".file-summary", Static).content + ) + + +async def test_unchanged_file_result_is_retained_on_activity_card(tmp_path): + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test(size=(100, 36)) as pilot: + turn = Turn("edit it", tmp_path) + await app.query_one("#conversation", VerticalScroll).mount(turn) + handler = TextualEventHandler(app, turn) + await handler.on_tool_start( + {"name": "edit_code"}, + "", + run_id="unchanged-edit", + inputs={"filename": "same.txt"}, + ) + await handler.on_tool_end( + "No changes made: content already matches", + run_id="unchanged-edit", + ) + await pilot.pause() + + card = turn.query_one(FileActivityCard) + assert card.outcomes[("Editing", "same.txt")] == ( + "unchanged", + "No changes made: content already matches", + ) + + +@pytest.mark.parametrize("outcome", ["failed", "unchanged"]) +async def test_rich_edit_outcome_updates_existing_diff_row(tmp_path, outcome): + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test(size=(100, 36)) as pilot: + turn = Turn("edit it", tmp_path) + await app.query_one("#conversation", VerticalScroll).mount(turn) + handler = TextualEventHandler(app, turn) + await handler.on_tool_start( + {"name": "edit_code"}, + "", + run_id="rich-edit", + inputs={ + "filename": "same.txt", + "old_code": "before", + "new_code": "after", + }, + ) + if outcome == "failed": + await handler.on_tool_error( + RuntimeError("edit failed"), run_id="rich-edit" + ) + expected = ("failed", "edit failed") + else: + message = "No changes made: content already matches" + await handler.on_tool_end(message, run_id="rich-edit") + expected = ("unchanged", message) + await pilot.pause() + + cards = list(turn.query(EditCard)) + assert len(cards) == 1 + assert expected[1] in str( + cards[0].query_one(".edit-outcome", Static).content + ) diff --git a/tests/cli/tui/test_widgets.py b/tests/cli/tui/test_widgets.py new file mode 100644 index 00000000..d6371b5d --- /dev/null +++ b/tests/cli/tui/test_widgets.py @@ -0,0 +1,2839 @@ +import asyncio +import os +import random +import threading +import time +from pathlib import Path +from threading import Event +from types import SimpleNamespace + +import yaml +from mcp import StdioServerParameters +from mcp.client.session_group import StreamableHttpParameters +from pydantic import SecretStr +from textual import events +from textual.binding import Binding +from textual.containers import Vertical, VerticalScroll +from textual.theme import BUILTIN_THEMES +from textual.widgets import ( + Collapsible, + Input, + Markdown, + Select, + Static, + Tab, + TabbedContent, + TabPane, + TextArea, +) + +import ursa.util.crossplatform as crossplatform +from tests.cli._app_fakes import FakeHITL +from ursa.agents.base import AgentWithTools +from ursa.agents.execution_agent import ExecutionAgent +from ursa.cli.config import ( + ChatModelConfig, + EmbModelConfig, + InferenceProviderConfig, +) +from ursa.cli.runtime import AgentHITL +from ursa.cli.tui.app import UrsaTextualApp +from ursa.cli.tui.tips import TIPS, random_tip, runtime_keymap +from ursa.cli.tui.widgets import ( + AgentsScreen, + FuzzySelectOverlay, + HotlistScreen, + InformationScreen, + ModelScreen, + ModelSelection, + PromptArea, + ThemeScreen, + ToolMessage, + WelcomeBanner, +) +from ursa.util.inference_providers import ProviderModel + + +async def wait_for_yaml_debounce(pilot) -> None: + """Let the YAML validation timer fire, then flush resulting messages.""" + await asyncio.sleep(ModelScreen.YAML_VALIDATION_DELAY + 0.1) + await pilot.pause() + + +class FakeToolArgs: + @classmethod + def model_json_schema(cls): + return { + "properties": { + "path": { + "type": "string", + "description": "Workspace-relative file path.", + } + }, + "required": ["path"], + } + + +class FakeConfiguredTool: + name = "read_file" + description = "Read a file from the configured workspace." + args_schema = FakeToolArgs + return_direct = False + metadata = None + + +def test_model_select_type_search_is_fuzzy(): + overlay = FuzzySelectOverlay() + overlay.add_options(["gpt-4", "gpt-5.4", "text-embedding-3-large"]) + + assert overlay._find_search_match("g54") == 1 + + +class FakeMcpTool(FakeConfiguredTool): + name = "remote_read" + + +async def test_agent_hotlist_routes_selected_agent(tmp_path): + hitl = FakeHITL(tmp_path) + app = UrsaTextualApp(hitl) + + async with app.run_test(size=(100, 36)) as pilot: + await pilot.press("#") + await pilot.pause() + assert isinstance(app.screen, HotlistScreen) + + options = app.screen.query_one("#hotlist-options") + assert options.highlighted == 0 + await pilot.press("p", "l") + assert app.screen.matches == ["plan"] + assert options.highlighted == 0 + await pilot.press("enter") + await pilot.pause() + prompt = app.query_one(PromptArea) + assert prompt.text == "#plan " + + prompt.insert("make a plan") + await pilot.press("enter") + await pilot.pause() + assert hitl.calls == [("plan", "make a plan")] + + +async def test_agent_selection_moves_to_front_replaces_and_preserves_cursor( + tmp_path, +): + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test(size=(100, 36)) as pilot: + prompt = app.query_one(PromptArea) + prompt.load_text("Review docs carefully") + prompt.move_cursor((0, 6)) + + await pilot.press("#", "p", "l", "enter") + await pilot.pause() + assert prompt.text == "#plan Review docs carefully" + assert prompt.cursor_location == (0, 12) + + await pilot.press("#", "c", "h", "enter") + await pilot.pause() + assert prompt.text == "#chat Review docs carefully" + assert prompt.cursor_location == (0, 12) + + +async def test_macro_selectors_close_with_escape(tmp_path): + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test(size=(100, 36)) as pilot: + prompt = app.query_one(PromptArea) + prompt.load_text("Review docs carefully") + prompt.move_cursor((0, 6)) + + await pilot.press("#") + await pilot.pause() + assert isinstance(app.screen, HotlistScreen) + await pilot.press("escape") + await pilot.pause() + + assert prompt.text == "Review# docs carefully" + assert prompt.cursor_location == (0, 7) + assert prompt.has_focus + + await pilot.press("ctrl+z") + assert prompt.text == "Review docs carefully" + await pilot.press("ctrl+y") + await pilot.pause() + assert prompt.text == "Review# docs carefully" + assert not isinstance(app.screen, HotlistScreen) + + prompt.load_text("") + await pilot.press("@") + await pilot.pause() + assert isinstance(app.screen, HotlistScreen) + await pilot.press("escape") + await pilot.pause() + assert prompt.text == "@" + assert prompt.has_focus + + +async def test_escaping_command_picker_preserves_multiline_draft_and_undo( + tmp_path, +): + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test(size=(100, 36)) as pilot: + prompt = app.query_one(PromptArea) + prompt.load_text("alpha\nbeta") + prompt.move_cursor((0, 0)) + + await pilot.press("/") + await pilot.pause() + assert isinstance(app.screen, HotlistScreen) + await pilot.press("escape") + await pilot.pause() + + assert prompt.text == "/alpha\nbeta" + await pilot.press("ctrl+z") + assert prompt.text == "alpha\nbeta" + await pilot.press("ctrl+y") + await pilot.pause() + assert prompt.text == "/alpha\nbeta" + assert not isinstance(app.screen, HotlistScreen) + + +async def test_macro_choice_is_undoable_without_reopening_picker(tmp_path): + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test(size=(100, 36)) as pilot: + prompt = app.query_one(PromptArea) + prompt.load_text("Review docs") + prompt.move_cursor((0, 6)) + + await pilot.press("#", "p", "l", "enter") + await pilot.pause() + assert prompt.text == "#plan Review docs" + + await pilot.press("ctrl+z") + await pilot.pause() + assert prompt.text == "Review# docs" + assert not isinstance(app.screen, HotlistScreen) + + await pilot.press("ctrl+y") + await pilot.pause() + assert prompt.text == "#plan Review docs" + assert not isinstance(app.screen, HotlistScreen) + + +async def test_programmatic_and_pasted_macro_characters_do_not_open_picker( + tmp_path, +): + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test(size=(100, 36)) as pilot: + prompt = app.query_one(PromptArea) + prompt.load_text("#plan programmatic") + await pilot.pause() + assert not isinstance(app.screen, HotlistScreen) + + prompt.load_text("") + app.post_message(events.Paste("@notes.md /status")) + await pilot.pause() + assert prompt.text == "@notes.md /status" + assert not isinstance(app.screen, HotlistScreen) + + +async def test_file_hotlist_uses_at_trigger(tmp_path): + (tmp_path / "notes.md").write_text("hello") + (tmp_path / "docs").mkdir() + (tmp_path / "docs" / "guide.md").write_text("guide") + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test(size=(100, 36)) as pilot: + await pilot.press("@") + await pilot.pause() + assert isinstance(app.screen, HotlistScreen) + assert app.screen.candidates == [ + f"{Path('docs')}{os.sep}", + str(Path("docs/guide.md")), + "notes.md", + ] + options = app.screen.query_one("#hotlist-options") + assert options.highlighted == 0 + + await pilot.press("n", "o") + assert app.screen.matches == ["notes.md"] + assert options.highlighted == 0 + await pilot.press("enter") + await pilot.pause() + prompt = app.query_one(PromptArea) + assert prompt.text == "@notes.md " + + prompt.load_text("") + await pilot.press("@") + await pilot.pause() + await pilot.press("d", "o", "c", "s", "enter") + await pilot.pause() + assert prompt.text == f"@{Path('docs')}{os.sep} " + + +async def test_shift_enter_adds_a_prompt_newline(tmp_path): + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test() as pilot: + await pilot.press("a", "shift+enter", "b") + assert app.query_one(PromptArea).text == "a\nb" + + +async def test_ctrl_j_adds_a_prompt_newline(tmp_path): + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test() as pilot: + await pilot.press("a", "ctrl+j", "b") + assert app.query_one(PromptArea).text == "a\nb" + + +def test_newline_key_prefers_shift_enter_when_protocol_is_detected( + tmp_path, monkeypatch +): + monkeypatch.setattr(crossplatform, "expects_kitty_keyboard", lambda: True) + app = UrsaTextualApp(FakeHITL(tmp_path)) + + assert app.preferred_newline_key == "shift+enter" + + +def test_newline_key_falls_back_to_ctrl_j(tmp_path, monkeypatch): + monkeypatch.setattr(crossplatform, "expects_kitty_keyboard", lambda: False) + app = UrsaTextualApp(FakeHITL(tmp_path)) + + assert app.preferred_newline_key == "ctrl+j" + + +async def test_prompt_has_markdown_highlighting_paste_undo_and_redo(tmp_path): + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test() as pilot: + prompt = app.query_one(PromptArea) + assert prompt.language == "markdown" + + app.post_message(events.Paste("# heading\nbody")) + await pilot.pause() + assert prompt.text == "# heading\nbody" + + await pilot.press("ctrl+z") + assert prompt.text == "" + await pilot.press("ctrl+y") + assert prompt.text == "# heading\nbody" + + +async def test_prompt_copy_shortcut_preserves_selected_text( + tmp_path, monkeypatch +): + app = UrsaTextualApp(FakeHITL(tmp_path)) + copied = [] + monkeypatch.setattr(app, "copy_to_clipboard", copied.append) + + async with app.run_test() as pilot: + prompt = app.query_one(PromptArea) + prompt.load_text("selected text") + prompt.action_select_all() + + await pilot.press("super+c") + + assert copied == ["selected text"] + assert prompt.text == "selected text" + + +async def test_prompt_copy_shortcut_delegates_to_screen_selection( + tmp_path, monkeypatch +): + app = UrsaTextualApp(FakeHITL(tmp_path)) + delegated = [] + + async with app.run_test() as pilot: + monkeypatch.setattr( + app.screen, "action_copy_text", lambda: delegated.append(True) + ) + + await pilot.press("ctrl+shift+c") + + assert delegated == [True] + + +async def test_prompt_supports_option_arrow_word_navigation(tmp_path): + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test() as pilot: + prompt = app.query_one(PromptArea) + prompt.load_text("alpha beta") + prompt.move_cursor((0, len(prompt.text))) + + await pilot.press("alt+left") + assert prompt.cursor_location == (0, 6) + await pilot.press("alt+right") + assert prompt.cursor_location == (0, 10) + + +async def test_ctrl_c_clears_prompt_and_adds_it_to_history(tmp_path): + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test() as pilot: + await pilot.press("o", "l", "d", "enter") + await pilot.pause() + await pilot.press("d", "r", "a", "f", "t", "ctrl+c") + prompt = app.query_one(PromptArea) + assert prompt.text == "" + + await pilot.press("up") + assert prompt.text == "draft" + await pilot.press("up") + assert prompt.text == "old" + await pilot.press("down") + assert prompt.text == "draft" + + +async def test_prompt_caps_at_thirty_percent_of_terminal_height(tmp_path): + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test(size=(100, 36)) as pilot: + prompt = app.query_one(PromptArea) + assert prompt.region.height == 3 # One content row plus the border. + + prompt.load_text("\n".join(str(index) for index in range(30))) + for _ in range(3): + await pilot.pause() + if prompt.region.height == 13: + break + assert prompt.region.height == 13 # ceil(36 * 0.3) plus the border. + + await pilot.resize_terminal(100, 20) + for _ in range(3): + await pilot.pause() + if prompt.region.height == 8: + break + assert prompt.region.height == 8 # ceil(20 * 0.3) plus the border. + + +async def test_prompt_grows_for_soft_wrapped_lines(tmp_path): + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test(size=(40, 24)) as pilot: + prompt = app.query_one(PromptArea) + prompt.load_text("word " * 40) + for _ in range(3): + await pilot.pause() + if prompt.region.height > 3: + break + + assert prompt.virtual_size.height > 1 + assert prompt.region.height == min(8, prompt.virtual_size.height) + 2 + + +async def test_welcome_banner_and_provider_status_are_visible(tmp_path): + hitl = FakeHITL(tmp_path) + hitl.config.inference_providers.update({ + "hosted-chat": InferenceProviderConfig(base_url="https://llm.test/v1"), + "hosted-embedding": InferenceProviderConfig( + base_url="https://embed.test/v1" + ), + }) + hitl.config.llm_model = ChatModelConfig( + model="test-model", inference_provider="hosted-chat" + ).resolve_inference_provider(hitl.config.inference_providers) + hitl.config.emb_model = EmbModelConfig( + model="embed-model", inference_provider="hosted-embedding" + ).resolve_inference_provider(hitl.config.inference_providers) + hitl.inference_provider = "hosted-chat" + hitl.embedding_inference_provider = "hosted-embedding" + hitl.group = "research" + app = UrsaTextualApp(hitl) + + async with app.run_test(size=(120, 40)) as pilot: + await pilot.pause() + banner = app.query_one(WelcomeBanner) + snapshot = str( + banner.query_one("#welcome-config-values", Static).content + ) + workspace = banner.query_one("#welcome-workspace", Static) + assert str(workspace.content).endswith(tmp_path.name[-12:]) + assert "test-model (hosted-chat - https://llm.test/v1)" in snapshot + assert ( + "embed-model (hosted-embedding - https://embed.test/v1)" in snapshot + ) + assert "research" in snapshot + assert "test-model (hosted-chat)" in str( + app.query_one("#status", Static).content + ) + assert "Ctrl+" not in str(app.query_one("#status", Static).content) + + +async def test_named_agent_appears_in_statusline_and_status_command(tmp_path): + hitl = FakeHITL(tmp_path) + hitl.agent_name = "lab-assistant" + hitl.config.agent_name = "lab-assistant" + app = UrsaTextualApp(hitl) + + async with app.run_test(size=(100, 36)): + assert "lab-assistant" in str(app.query_one("#status", Static).content) + assert "lab-assistant" in app._status_markdown() + + +async def test_welcome_tips_vary_and_keymaps_resolve(tmp_path): + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test(size=(80, 24)): + owners = (type(app), PromptArea, HotlistScreen) + keymap = runtime_keymap(app, owners) + assert all(tip.format_map(keymap) for tip in TIPS) + assert len({random_tip(app, owners) for _ in range(100)}) > 1 + + +async def test_welcome_tip_border_uses_visible_theme_border(tmp_path): + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test(size=(80, 24)) as pilot: + tip = app.query_one("#welcome-tip", Static) + for theme_name in ("ursa-dark", "ursa-light"): + app.theme = theme_name + await pilot.pause() + + theme_colors = ( + app.get_theme(theme_name).to_color_system().generate() + ) + border_style, border_color = tip.styles.border.top + assert border_style == "round" + assert border_color.hex == theme_colors["border"] + assert border_color != app.screen.styles.background + + +async def test_welcome_version_and_workspace_align_at_narrow_width(tmp_path): + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test(size=(80, 24)) as pilot: + await pilot.pause() + logo = app.query_one("#welcome-logo") + config = app.query_one("#welcome-config") + art = app.query_one("#welcome-logo-art", Static) + version = app.query_one("#welcome-version", Static) + workspace_row = app.query_one("#welcome-workspace-row") + label = app.query_one("#welcome-workspace-label", Static) + workspace = app.query_one("#welcome-workspace", Static) + + assert version.styles.content_align_horizontal == "right" + assert version.region.right == logo.content_region.right + assert version.region.y == art.region.bottom + assert len(str(version.content)) <= version.content_region.width + assert workspace_row.has_class("workspace-stacked") + assert workspace.styles.content_align_horizontal == "right" + assert workspace.styles.text_overflow == "ellipsis" + assert workspace.region.right == config.content_region.right + assert workspace.region.y > label.region.y + assert len(str(workspace.content)) <= workspace.content_region.width + assert str(workspace.content).endswith(tmp_path.name[-12:]) + + +async def test_workspace_uses_one_borderless_row_when_it_fits(tmp_path): + hitl = FakeHITL(tmp_path) + hitl.workspace = Path("/tmp/ursa") + app = UrsaTextualApp(hitl) + + async with app.run_test(size=(120, 40)) as pilot: + await pilot.pause() + row = app.query_one("#welcome-workspace-row") + label = app.query_one("#welcome-workspace-label", Static) + workspace = app.query_one("#welcome-workspace", Static) + values = app.query_one("#welcome-config-values", Static) + + assert row.has_class("workspace-inline") + assert label.region.y == workspace.region.y + assert workspace.styles.content_align_horizontal == "left" + assert workspace.region.x == label.region.right + assert values.region.y == row.region.bottom + assert str(workspace.content) == str(Path("/tmp/ursa").resolve()) + + +async def test_picker_header_shares_the_top_row_with_exit_hint(tmp_path): + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test(size=(80, 24)) as pilot: + app.push_screen(HotlistScreen("Workspace paths", ["src/"])) + await pilot.pause() + hotlist = app.screen.query_one("#hotlist") + header = app.screen.query_one("#hotlist-header") + title = app.screen.query_one("#hotlist-title", Static) + exit_hint = app.screen.query_one("#hotlist-exit-hint", Static) + + assert header.region.y == hotlist.content_region.y + assert title.region.y == exit_hint.region.y + assert str(exit_hint.content) == "Esc to Exit" + + +async def test_slash_picker_opens_status_inside_textual(tmp_path): + hitl = FakeHITL(tmp_path) + hitl.agent_name = "lab-assistant" + hitl.config.agent_name = "lab-assistant" + hitl.config.llm_model.api_key = SecretStr("actual-secret") + hitl.config.mcp_servers = { + "local": StdioServerParameters(command="ursa-mcp", args=[]), + "remote": StreamableHttpParameters(url="https://example.test/mcp"), + **{ + f"extra-{index}": StdioServerParameters( + command=f"ursa-mcp-{index}", args=[] + ) + for index in range(20) + }, + } + app = UrsaTextualApp(hitl) + + async with app.run_test(size=(80, 24)) as pilot: + await pilot.press("/") + await pilot.pause() + assert isinstance(app.screen, HotlistScreen) + hotlist = app.screen.query_one("#hotlist") + options = app.screen.query_one("#hotlist-options") + assert hotlist.region.width == 80 + assert options.region.height >= 3 + assert options.region.bottom <= hotlist.region.bottom + screenshot = app.export_screenshot() + assert "agents" in screenshot + assert [ + candidate.partition(" — ")[0] for candidate in app.screen.candidates + ] == ["agents", "exit", "status", "keymap", "models", "theme"] + + await pilot.press("s", "t", "a", "t", "u", "s", "enter") + await pilot.pause() + assert isinstance(app.screen, InformationScreen) + assert "LLM Endpoint" in app.screen.content + assert "lab-assistant" in app.screen.content + assert "MCP servers" in app.screen.content + assert "ursa-mcp" in app.screen.content + assert "https://example.test/mcp" in app.screen.content + + tabs = {str(tab.label): tab for tab in app.screen.query(Tab)} + await pilot.click(f"#{tabs['Config'].id}") + await pilot.press("tab") + await pilot.pause() + editor = app.screen.query_one("#status-config-yaml", TextArea) + assert app.focused is editor + assert editor.read_only + assert editor.language == "yaml" + assert type(editor.document).__name__ == "SyntaxAwareDocument" + assert editor._highlight_query is not None + assert "llm_model:" in editor.text + assert "model: test-model" in editor.text + assert "env: OPENAI_API_KEY" in editor.text + assert "**********" in editor.text + assert "actual-secret" not in editor.text + assert str( + app.screen.query_one("#status-config-readonly", Static).content + ).startswith("Read only") + await pilot.press("down", "shift+down") + assert editor.cursor_location[0] == 2 + assert not editor.selection.is_empty + + await pilot.click(f"#{tabs['Status'].id}") + await pilot.press("tab") + body = app.screen.query_one("#information-body", VerticalScroll) + assert app.focused is body + assert body.scroll_y == 0 + await pilot.press("end") + await pilot.pause() + assert body.scroll_y > 0 + + await pilot.press("escape") + await pilot.pause() + assert not isinstance(app.screen, InformationScreen) + + +async def test_exit_command_quits_the_app(tmp_path): + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test(size=(80, 24)) as pilot: + await pilot.press("/", "e", "x", "i", "t", "enter") + await pilot.pause() + + assert app._exit + + +async def test_model_command_switches_provider_and_model(tmp_path, monkeypatch): + hitl = FakeHITL(tmp_path) + hitl.config.llm_model.max_completion_tokens = 4096 + hitl.inference_provider = "stale-provider" + hitl.config.inference_providers["stale-provider"] = InferenceProviderConfig( + base_url="https://stale.example/v1" + ) + + def provider_models(config): + if config.base_url == "https://stale.example/v1": + return [ProviderModel("claude-stale", "anthropic")] + return [ + ProviderModel("gpt-5.4", "openai"), + ProviderModel("text-embedding-3-large", "openai", type="embedding"), + ] + + monkeypatch.setattr( + "ursa.cli.tui.widgets.list_provider_models", + provider_models, + ) + app = UrsaTextualApp(hitl) + + async with app.run_test(size=(80, 24)) as pilot: + await app._show_command("models") + await pilot.pause() + await app.workers.wait_for_complete() + assert isinstance(app.screen, ModelScreen) + assert ( + app.screen.query_one("#chat-inference-provider", Select).value + == "openai" + ) + assert app.screen.query_one( + "#chat-inference-provider", Select + )._options == [ + ("None (direct model config)", ModelScreen.NONE_VALUE), + ("openai (https://api.openai.com/v1)", "openai"), + ("stale-provider (https://stale.example/v1)", "stale-provider"), + ] + assert ( + app.screen.query_one("#chat-model-label").tooltip + == (ModelScreen.FIELD_HELP["model"]) + ) + assert ( + app.screen.query_one("#chat-model-provider-label").tooltip + == ModelScreen.FIELD_HELP["model-provider"] + ) + assert ( + app.screen.query_one("#chat-inference-provider-label").tooltip + == ModelScreen.FIELD_HELP["inference-provider"] + ) + assert app.screen.query_one("#chat-model-name", Select)._options == [ + ("None", ModelScreen.NONE_VALUE), + ("gpt-5.4", "gpt-5.4"), + ("text-embedding-3-large", "text-embedding-3-large"), + ("Not found: test-model", "test-model"), + ("Other…", ModelScreen.CUSTOM_VALUE), + ] + model_select = app.screen.query_one("#chat-model-name", Select) + model_select.focus() + await pilot.press("enter", "g") + await asyncio.sleep(0.8) + await pilot.press("5", "4") + fuzzy_options = model_select.query_one(FuzzySelectOverlay) + assert str(model_select.query_one("#label", Static).content) == "g54" + assert fuzzy_options.border_title is None + assert fuzzy_options.option_count == 1 + await pilot.press("escape") + assert isinstance(app.screen, ModelScreen) + assert not model_select.expanded + model_select.focus() + await pilot.press("enter") + assert model_select.expanded + await pilot.click("#chat-model-provider") + assert not model_select.expanded + model_select.focus() + await pilot.press("enter", "g", "5", "4") + assert fuzzy_options.option_count == 1 + assert fuzzy_options.highlighted == 0 + assert fuzzy_options.get_option_at_index(0).id == "1" + await pilot.press("enter") + assert model_select.value == "gpt-5.4" + app.screen.query_one( + "#chat-inference-provider", Select + ).value = "stale-provider" + await pilot.pause() + await app.workers.wait_for_complete() + assert app.screen.query_one("#chat-model-name", Select)._options == [ + ("None", ModelScreen.NONE_VALUE), + ("claude-stale", "claude-stale"), + ("Not found: gpt-5.4", "gpt-5.4"), + ("Other…", ModelScreen.CUSTOM_VALUE), + ] + assert ( + app.screen.query_one("#chat-model-name", Select).value == "gpt-5.4" + ) + assert app.screen.query_one("#chat-model-name-custom", Input).has_class( + "hidden" + ) + app.screen.query_one("#chat-model-name", Select).value = "claude-stale" + await pilot.pause() + assert ( + app.screen.query_one("#chat-model-provider", Select).value + == "anthropic" + ) + assert app.screen.query_one( + "#embedding-model-provider", Select + )._options[0] == ("None", ModelScreen.NONE_VALUE) + app.screen.query_one( + "#chat-inference-provider", Select + ).value = "openai" + await pilot.pause() + await app.workers.wait_for_complete() + app.screen.query_one("#chat-model-name", Select).value = "gpt-5.4" + await pilot.pause() + app.screen.query_one( + "#embedding-model-name", Select + ).value = ModelScreen.CUSTOM_VALUE + app.screen.query_one( + "#embedding-model-name-custom", Input + ).value = "private-embedding" + app.screen.query_one( + "#embedding-model-provider", Select + ).value = ModelScreen.NONE_VALUE + await pilot.press("ctrl+enter") + await pilot.pause() + await app.workers.wait_for_complete() + + messages = list(app.query(ToolMessage)) + assert [message.content for message in messages[-2:]] == [ + "Changed the chat model to gpt-5.4 " + "(openai - https://api.openai.com/v1)", + "Changed the embedding model to private-embedding " + "(openai - https://api.openai.com/v1)", + ] + welcome = str(app.query_one("#welcome-config-values", Static).content) + assert ( + "LLM gpt-5.4 (openai - https://api.openai.com/v1)" in welcome + ) + assert ( + "Embedding private-embedding " + "(openai - https://api.openai.com/v1)" in welcome + ) + + assert len(hitl.model_changes) == 1 + chat_config, embedding_config = hitl.model_changes[0] + assert isinstance(chat_config, ChatModelConfig) + assert chat_config.model == "gpt-5.4" + assert chat_config.model_provider == "openai" + assert chat_config.inference_provider == "openai" + assert chat_config.max_completion_tokens == 4096 + assert isinstance(embedding_config, EmbModelConfig) + assert embedding_config.model == "private-embedding" + assert embedding_config.model_provider is None + assert embedding_config.inference_provider == "openai" + + +async def test_invalid_model_selection_notifies_without_closing_modal( + tmp_path, monkeypatch +): + app = UrsaTextualApp(FakeHITL(tmp_path)) + notifications = [] + monkeypatch.setattr( + app, + "notify", + lambda message, **kwargs: notifications.append((message, kwargs)), + ) + + def invalid_settings(*_args): + raise ValueError("invalid model configuration") + + async with app.run_test(size=(80, 24)) as pilot: + await app._show_command("models") + await pilot.pause() + await app.workers.wait_for_complete() + notifications.clear() + assert isinstance(app.screen, ModelScreen) + monkeypatch.setattr( + app.screen, + "_settings", + invalid_settings, + ) + + app.screen.action_apply() + + assert isinstance(app.screen, ModelScreen) + assert notifications == [ + ( + "invalid model configuration", + { + "title": "Model not changed", + "severity": "error", + "timeout": 10, + "markup": False, + }, + ) + ] + + +async def test_model_yaml_round_trips_extras_and_updates_controls( + tmp_path, monkeypatch +): + hitl = FakeHITL(tmp_path) + monkeypatch.setattr( + "ursa.cli.tui.widgets.list_provider_models", + lambda _config: [ProviderModel("yaml-model", "openai")], + ) + app = UrsaTextualApp(hitl) + + async with app.run_test(size=(80, 30)) as pilot: + await app._show_command("models") + await pilot.pause() + await app.workers.wait_for_complete() + assert isinstance(app.screen, ModelScreen) + editor = app.screen.query_one("#chat-config-yaml", TextArea) + assert type(editor.document).__name__ == "SyntaxAwareDocument" + assert editor._highlight_query is not None + editor.text = """\ +temperature: 0.25 +model: yaml-model +model_provider: openai +inference_provider: openai +provider_options: + reasoning: high +""" + await pilot.pause() + assert not editor.has_class("yaml-valid", "yaml-invalid") + await wait_for_yaml_debounce(pilot) + + assert editor.has_class("yaml-valid") + assert ( + app.screen.query_one("#chat-model-name", Select).value + == "yaml-model" + ) + assert app.screen.drafts["chat"].model_extra == { + "temperature": 0.25, + "provider_options": {"reasoning": "high"}, + } + + app.screen.query_one( + "#chat-model-name", Select + ).value = ModelScreen.CUSTOM_VALUE + app.screen.query_one( + "#chat-model-name-custom", Input + ).value = "changed-model" + await pilot.pause() + + assert "model: changed-model" in editor.text + assert "temperature: 0.25" in editor.text + assert "reasoning: high" in editor.text + assert editor.text.index("temperature:") < editor.text.index("model:") + + +async def test_valid_yaml_is_committed_only_when_apply_is_pressed( + tmp_path, monkeypatch +): + monkeypatch.setattr( + "ursa.cli.tui.widgets.list_provider_models", lambda _config: [] + ) + hitl = FakeHITL(tmp_path) + original = hitl.config.llm_model.model_copy(deep=True) + app = UrsaTextualApp(hitl) + + async with app.run_test(size=(80, 30)) as pilot: + await app._show_command("models") + await pilot.pause() + await app.workers.wait_for_complete() + assert isinstance(app.screen, ModelScreen) + editor = app.screen.query_one("#chat-config-yaml", TextArea) + editor.text = """\ +model: applied-model +model_provider: openai +inference_provider: openai +temperature: 0.35 +provider_options: + reasoning: high +""" + assert hitl.config.llm_model == original + + app.screen.action_apply() + await app.workers.wait_for_complete() + await pilot.pause() + + assert hitl.config.llm_model.model == "applied-model" + assert hitl.config.llm_model.model_extra == { + "temperature": 0.35, + "provider_options": {"reasoning": "high"}, + } + + +async def test_yaml_validation_keeps_unavailable_model_as_not_found( + tmp_path, monkeypatch +): + monkeypatch.setattr( + "ursa.cli.tui.widgets.list_provider_models", + lambda _config: [ProviderModel("available-model", "openai")], + ) + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test(size=(80, 30)) as pilot: + await app._show_command("models") + await pilot.pause() + await app.workers.wait_for_complete() + assert isinstance(app.screen, ModelScreen) + editor = app.screen.query_one("#chat-config-yaml", TextArea) + editor.text = """\ +model: unavailable-model +model_provider: openai +inference_provider: openai +""" + + await pilot.pause() + assert not editor.has_class("yaml-valid", "yaml-invalid") + await wait_for_yaml_debounce(pilot) + + model_select = app.screen.query_one("#chat-model-name", Select) + assert model_select.value == "unavailable-model" + assert ( + "Not found: unavailable-model", + "unavailable-model", + ) in model_select._options + assert app.screen.query_one("#chat-model-name-custom", Input).has_class( + "hidden" + ) + + +async def test_yaml_keeps_unavailable_model_provider_visible( + tmp_path, monkeypatch +): + monkeypatch.setattr( + "ursa.cli.tui.widgets.list_provider_models", lambda _config: [] + ) + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test(size=(80, 30)) as pilot: + await app._show_command("models") + await pilot.pause() + await app.workers.wait_for_complete() + assert isinstance(app.screen, ModelScreen) + editor = app.screen.query_one("#chat-config-yaml", TextArea) + editor.text = """\ +model: custom-model +model_provider: future_provider +inference_provider: openai +""" + + await pilot.pause() + assert not editor.has_class("yaml-valid", "yaml-invalid") + await wait_for_yaml_debounce(pilot) + + provider = app.screen.query_one("#chat-model-provider", Select) + assert provider.value == "future_provider" + assert ( + "Not found: future_provider", + "future_provider", + ) in provider._options + assert app.screen.drafts["chat"].model_provider == "future_provider" + + editor.text = """\ +model: custom-model +model_provider: another_future_provider +inference_provider: openai +""" + await pilot.pause() + await wait_for_yaml_debounce(pilot) + + assert provider.value == "another_future_provider" + assert ( + "Not found: another_future_provider", + "another_future_provider", + ) in provider._options + assert ( + "Not found: future_provider", + "future_provider", + ) not in provider._options + + editor.text = """\ +model: custom-model +model_provider: openai +inference_provider: openai +""" + await pilot.pause() + await wait_for_yaml_debounce(pilot) + + assert provider.value == "openai" + assert not any( + label.startswith("Not found:") for label, _ in provider._options + ) + + +async def test_whitespace_chat_model_yaml_is_rejected_before_apply( + tmp_path, monkeypatch +): + monkeypatch.setattr( + "ursa.cli.tui.widgets.list_provider_models", lambda _config: [] + ) + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test(size=(80, 30)) as pilot: + await app._show_command("models") + await pilot.pause() + await app.workers.wait_for_complete() + assert isinstance(app.screen, ModelScreen) + editor = app.screen.query_one("#chat-config-yaml", TextArea) + editor.text = "model: ' '\nmodel_provider: openai\n" + + app.screen.action_apply() + + assert isinstance(app.screen, ModelScreen) + assert editor.has_class("yaml-invalid") + assert "Chat model must not be blank" in str( + app.screen.query_one("#chat-yaml-error", Static).content + ) + + +async def test_whitespace_embedding_model_yaml_is_rejected_before_apply( + tmp_path, monkeypatch +): + monkeypatch.setattr( + "ursa.cli.tui.widgets.list_provider_models", lambda _config: [] + ) + hitl = FakeHITL(tmp_path) + app = UrsaTextualApp(hitl) + + async with app.run_test(size=(80, 30)) as pilot: + await app._show_command("models") + await pilot.pause() + await app.workers.wait_for_complete() + assert isinstance(app.screen, ModelScreen) + editor = app.screen.query_one("#embedding-config-yaml", TextArea) + editor.text = "model: ' '\nmodel_provider: openai\n" + + app.screen.action_apply() + + assert isinstance(app.screen, ModelScreen) + assert editor.has_class("yaml-invalid") + assert "Embedding model must not be blank" in str( + app.screen.query_one("#embedding-yaml-error", Static).content + ) + + editor.text = "model: ''\nmodel_provider: openai\n" + app.screen.action_apply() + await pilot.pause() + await app.workers.wait_for_complete() + assert hitl.config.emb_model is None + + +async def test_structured_chat_none_updates_yaml_and_blocks_apply( + tmp_path, monkeypatch +): + monkeypatch.setattr( + "ursa.cli.tui.widgets.list_provider_models", lambda _config: [] + ) + hitl = FakeHITL(tmp_path) + original = hitl.config.llm_model.model_copy(deep=True) + app = UrsaTextualApp(hitl) + + async with app.run_test(size=(80, 30)) as pilot: + await app._show_command("models") + await pilot.pause() + await app.workers.wait_for_complete() + assert isinstance(app.screen, ModelScreen) + app.screen.query_one( + "#chat-model-name", Select + ).value = ModelScreen.NONE_VALUE + await pilot.pause() + + editor = app.screen.query_one("#chat-config-yaml", TextArea) + assert yaml.safe_load(editor.text)["model"] == "" + assert app.screen.drafts["chat"].model == "" + + app.screen.action_apply() + + assert isinstance(app.screen, ModelScreen) + assert editor.has_class("yaml-invalid") + assert hitl.config.llm_model == original + + +async def test_structured_embedding_none_removes_configured_embedding( + tmp_path, monkeypatch +): + monkeypatch.setattr( + "ursa.cli.tui.widgets.list_provider_models", lambda _config: [] + ) + hitl = FakeHITL(tmp_path) + app = UrsaTextualApp(hitl) + + async with app.run_test(size=(80, 30)) as pilot: + await app._show_command("models") + await pilot.pause() + await app.workers.wait_for_complete() + assert isinstance(app.screen, ModelScreen) + app.screen.query_one( + "#embedding-model-name", Select + ).value = ModelScreen.NONE_VALUE + await pilot.pause() + + editor = app.screen.query_one("#embedding-config-yaml", TextArea) + assert yaml.safe_load(editor.text)["model"] == "" + assert app.screen.drafts["embedding"].model == "" + assert editor.has_class("yaml-valid") + + app.screen.action_apply() + await pilot.pause() + await app.workers.wait_for_complete() + + assert hitl.config.emb_model is None + + +async def test_yaml_validation_names_unknown_inference_provider( + tmp_path, monkeypatch +): + monkeypatch.setattr( + "ursa.cli.tui.widgets.list_provider_models", lambda _config: [] + ) + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test(size=(80, 30)) as pilot: + await app._show_command("models") + await pilot.pause() + await app.workers.wait_for_complete() + assert isinstance(app.screen, ModelScreen) + editor = app.screen.query_one("#chat-config-yaml", TextArea) + editor.text = "model: test-model\ninference_provider: missing\n" + + app.screen.action_apply() + + assert isinstance(app.screen, ModelScreen) + assert editor.has_class("yaml-invalid") + assert ( + str(app.screen.query_one("#chat-yaml-error", Static).content) + == "Unknown inference_provider 'missing'" + ) + + +async def test_model_option_refresh_preserves_sync_guard_and_empty_choice( + tmp_path, monkeypatch +): + monkeypatch.setattr( + "ursa.cli.tui.widgets.list_provider_models", lambda _config: [] + ) + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test(size=(80, 30)) as pilot: + await app._show_command("models") + await pilot.pause() + await app.workers.wait_for_complete() + assert isinstance(app.screen, ModelScreen) + + app.screen._syncing_controls = True + app.screen._set_model_options("embedding", {}, "") + + options = app.screen.query_one("#embedding-model-name", Select)._options + assert app.screen._syncing_controls is True + assert not any(label.startswith("Not found:") for label, _ in options) + + +async def test_programmatic_control_sync_suppresses_queued_events( + tmp_path, monkeypatch +): + discoveries = [] + + def provider_models(config): + discoveries.append(config) + return [] + + monkeypatch.setattr( + "ursa.cli.tui.widgets.list_provider_models", provider_models + ) + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test(size=(80, 30)) as pilot: + await app._show_command("models") + await pilot.pause() + await app.workers.wait_for_complete() + assert isinstance(app.screen, ModelScreen) + discoveries.clear() + updated = app.screen.drafts["chat"].model_copy( + update={"model": "programmatic-model"} + ) + app.screen.drafts["chat"] = updated + app.screen._yaml_values["chat"] = app.screen._configured_values(updated) + editor = app.screen.query_one("#chat-config-yaml", TextArea) + editor.text = app.screen._yaml_text("chat") + + app.screen._update_controls_from_config("chat", updated) + await pilot.pause() + + assert app.screen.drafts["chat"] is updated + assert editor.text == app.screen._yaml_text("chat") + assert discoveries == [] + + +async def test_masked_yaml_api_key_preserves_secret_value( + tmp_path, monkeypatch +): + monkeypatch.setattr( + "ursa.cli.tui.widgets.list_provider_models", lambda _: [] + ) + hitl = FakeHITL(tmp_path) + hitl.config.llm_model = ChatModelConfig( + model="secret-model", + model_provider="openai", + base_url="https://secret.example/v1", + api_key=SecretStr("actual-secret"), + ) + app = UrsaTextualApp(hitl) + + async with app.run_test(size=(80, 30)) as pilot: + await app._show_command("models") + await pilot.pause() + await app.workers.wait_for_complete() + assert isinstance(app.screen, ModelScreen) + editor = app.screen.query_one("#chat-config-yaml", TextArea) + assert "actual-secret" not in editor.text + assert "api_key: '**********'" in editor.text + + validated = app.screen._validate_yaml("chat", update_controls=True) + + assert isinstance(validated, ChatModelConfig) + assert isinstance(validated.api_key, SecretStr) + assert validated.api_key.get_secret_value() == "actual-secret" + assert app.screen._yaml_values["chat"]["api_key"] == "**********" + + +async def test_direct_provider_selection_refreshes_model_catalog( + tmp_path, monkeypatch +): + calls = [] + + def provider_models(config): + calls.append(config) + if isinstance(config, ChatModelConfig): + return [ProviderModel("direct-only", "openai")] + return [ProviderModel("provider-only", "openai")] + + monkeypatch.setattr( + "ursa.cli.tui.widgets.list_provider_models", provider_models + ) + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test(size=(80, 30)) as pilot: + await app._show_command("models") + await pilot.pause() + await app.workers.wait_for_complete() + assert isinstance(app.screen, ModelScreen) + + app.screen.query_one( + "#chat-inference-provider", Select + ).value = ModelScreen.NONE_VALUE + await pilot.pause() + await app.workers.wait_for_complete() + + options = app.screen.query_one("#chat-model-name", Select)._options + assert ("direct-only", "direct-only") in options + assert ("provider-only", "provider-only") not in options + assert any( + isinstance(config, ChatModelConfig) + and config.inference_provider is None + for config in calls + ) + + +async def test_yaml_provider_change_refreshes_model_catalog( + tmp_path, monkeypatch +): + hitl = FakeHITL(tmp_path) + hitl.config.inference_providers["fast"] = InferenceProviderConfig( + base_url="https://fast.example/v1" + ) + + def provider_models(config): + name = ( + "fast-only" + if config.base_url == "https://fast.example/v1" + else "old-only" + ) + return [ProviderModel(name, "openai")] + + monkeypatch.setattr( + "ursa.cli.tui.widgets.list_provider_models", provider_models + ) + app = UrsaTextualApp(hitl) + + async with app.run_test(size=(80, 30)) as pilot: + await app._show_command("models") + await pilot.pause() + await app.workers.wait_for_complete() + assert isinstance(app.screen, ModelScreen) + editor = app.screen.query_one("#chat-config-yaml", TextArea) + editor.text = """\ +model: test-model +model_provider: openai +inference_provider: fast +""" + + await pilot.pause() + assert not editor.has_class("yaml-valid", "yaml-invalid") + await wait_for_yaml_debounce(pilot) + await app.workers.wait_for_complete() + + assert ( + app.screen.query_one("#chat-inference-provider", Select).value + == "fast" + ) + assert "fast-only" in app.screen.model_catalogs["chat"] + assert "old-only" not in app.screen.model_catalogs["chat"] + + +async def test_provider_discovery_failure_clears_previous_catalog( + tmp_path, monkeypatch +): + hitl = FakeHITL(tmp_path) + hitl.config.inference_providers["broken"] = InferenceProviderConfig( + base_url="https://broken.example/v1" + ) + + def provider_models(config): + if config.base_url == "https://broken.example/v1": + raise RuntimeError("discovery failed") + return [ProviderModel("old-only", "openai")] + + monkeypatch.setattr( + "ursa.cli.tui.widgets.list_provider_models", provider_models + ) + app = UrsaTextualApp(hitl) + + async with app.run_test(size=(80, 30)) as pilot: + await app._show_command("models") + await pilot.pause() + await app.workers.wait_for_complete() + assert isinstance(app.screen, ModelScreen) + assert "old-only" in app.screen.model_catalogs["chat"] + + app.screen.query_one( + "#chat-inference-provider", Select + ).value = "broken" + await pilot.pause() + await app.workers.wait_for_complete() + + select = app.screen.query_one("#chat-model-name", Select) + assert app.screen.model_catalogs["chat"] == {} + assert ("old-only", "old-only") not in select._options + assert ("Not found: test-model", "test-model") in select._options + + +async def test_expanded_advanced_modal_is_scrollable_on_short_terminal( + tmp_path, monkeypatch +): + monkeypatch.setattr( + "ursa.cli.tui.widgets.list_provider_models", lambda _config: [] + ) + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test(size=(80, 20)) as pilot: + await app._show_command("models") + await pilot.pause() + await app.workers.wait_for_complete() + assert isinstance(app.screen, ModelScreen) + app.screen.query_one("#chat-advanced", Collapsible).collapsed = False + await pilot.pause() + + dialog = app.screen.query_one(".settings-dialog") + assert dialog.region.y >= 0 + assert dialog.region.bottom <= app.screen.size.height + assert dialog.max_scroll_y > 0 + + dialog.scroll_end(animate=False) + await pilot.pause() + actions = app.screen.query_one(".settings-actions") + assert actions.region.y >= 0 + assert actions.region.bottom <= app.screen.size.height + + +async def test_advanced_yaml_seeded_fuzz_never_mutates_running_config( + tmp_path, monkeypatch +): + monkeypatch.setattr( + "ursa.cli.tui.widgets.list_provider_models", lambda _config: [] + ) + hitl = FakeHITL(tmp_path) + original = hitl.config.llm_model.model_copy(deep=True) + app = UrsaTextualApp(hitl) + cases = [ + ("model: valid\nmodel_provider: openai\n", True), + ("model: [broken", False), + ("- model\n- list\n", False), + ("null\n", False), + ("model: valid\ninference_provider: missing\n", False), + ( + "model: valid\nbase_url: https://example.test\n" + "inference_provider: openai\n", + False, + ), + ("model: valid\napi_key:\n env:\n", False), + ( + "advanced_first:\n nested: [one, two]\n" + "model: valid\ntemperature: 0.4\n", + True, + ), + ("model: ''\n", False), + ] + random.Random(20260826).shuffle(cases) + + async with app.run_test(size=(80, 30)) as pilot: + await app._show_command("models") + await pilot.pause() + await app.workers.wait_for_complete() + assert isinstance(app.screen, ModelScreen) + advanced = app.screen.query_one("#chat-advanced", Collapsible) + editor = app.screen.query_one("#chat-config-yaml", TextArea) + assert advanced.collapsed + assert editor.language == "yaml" + + for document, expected_valid in cases: + editor.text = document + await pilot.pause() + assert not editor.has_class("yaml-valid", "yaml-invalid") + app.screen._yaml_timers["chat"].stop() + + result = app.screen._validate_yaml( + "chat", update_controls=expected_valid + ) + + assert (result is not None) is expected_valid + assert editor.has_class( + "yaml-valid" if expected_valid else "yaml-invalid" + ) + assert hitl.config.llm_model == original + if result is not None: + expected = yaml.safe_load(document) + assert result.model == expected["model"] + assert app.screen.drafts["chat"] == result + expected_choice = result.model or ModelScreen.NONE_VALUE + assert ( + app.screen.query_one("#chat-model-name", Select).value + == expected_choice + ) + assert result.model_extra == { + key: value + for key, value in expected.items() + if key + not in { + "model", + "model_provider", + "base_url", + "api_key", + "inference_provider", + "ssl_verify", + "max_completion_tokens", + } + } + await pilot.pause() + + +async def test_cancel_discards_yaml_with_pending_validation( + tmp_path, monkeypatch +): + monkeypatch.setattr(ModelScreen, "YAML_VALIDATION_DELAY", 30.0) + monkeypatch.setattr( + "ursa.cli.tui.widgets.list_provider_models", lambda _config: [] + ) + hitl = FakeHITL(tmp_path) + original = hitl.config.llm_model.model_copy(deep=True) + app = UrsaTextualApp(hitl) + + async with app.run_test(size=(80, 30)) as pilot: + await app._show_command("models") + await pilot.pause() + await app.workers.wait_for_complete() + assert isinstance(app.screen, ModelScreen) + app.screen.query_one( + "#chat-config-yaml", TextArea + ).text = "model: changed-before-cancel\n" + await pilot.pause() + # Flush the Changed event after the editor has emitted it to the screen. + await pilot.pause() + timer = app.screen._yaml_timers["chat"] + assert timer._task is not None + assert not timer._task.done() + + app.screen.action_cancel() + await pilot.pause() + + assert not isinstance(app.screen, ModelScreen) + assert hitl.config.llm_model == original + assert timer._task is None + + +async def test_yaml_debounce_restarts_from_latest_edit(tmp_path, monkeypatch): + monkeypatch.setattr( + "ursa.cli.tui.widgets.list_provider_models", lambda _config: [] + ) + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test(size=(80, 30)) as pilot: + await app._show_command("models") + await pilot.pause() + await app.workers.wait_for_complete() + assert isinstance(app.screen, ModelScreen) + editor = app.screen.query_one("#chat-config-yaml", TextArea) + editor.text = "model: first-edit\n" + await pilot.pause() + await asyncio.sleep(ModelScreen.YAML_VALIDATION_DELAY / 2) + + editor.text = "model: second-edit\n" + await pilot.pause() + await asyncio.sleep(ModelScreen.YAML_VALIDATION_DELAY / 2 + 0.05) + await pilot.pause() + + assert not editor.has_class("yaml-valid", "yaml-invalid") + await asyncio.sleep(ModelScreen.YAML_VALIDATION_DELAY / 2 + 0.1) + await pilot.pause() + assert editor.has_class("yaml-valid") + assert app.screen.drafts["chat"].model == "second-edit" + + +async def test_invalid_yaml_blocks_model_accept(tmp_path, monkeypatch): + monkeypatch.setattr( + "ursa.cli.tui.widgets.list_provider_models", lambda _config: [] + ) + app = UrsaTextualApp(FakeHITL(tmp_path)) + notifications = [] + monkeypatch.setattr( + app, + "notify", + lambda message, **kwargs: notifications.append((message, kwargs)), + ) + + async with app.run_test(size=(80, 30)) as pilot: + await app._show_command("models") + await pilot.pause() + await app.workers.wait_for_complete() + assert isinstance(app.screen, ModelScreen) + editor = app.screen.query_one("#chat-config-yaml", TextArea) + editor.text = "model: [not valid" + + app.screen.action_apply() + + assert isinstance(app.screen, ModelScreen) + assert editor.has_class("yaml-invalid") + assert notifications[-1][1]["severity"] == "error" + assert app.screen.query_one("#chat-yaml-error", Static).content + + +async def test_yaml_validation_error_with_rich_markup_is_plain_text( + tmp_path, monkeypatch +): + monkeypatch.setattr( + "ursa.cli.tui.widgets.list_provider_models", lambda _config: [] + ) + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test(size=(80, 30)) as pilot: + await app._show_command("models") + await pilot.pause() + await app.workers.wait_for_complete() + assert isinstance(app.screen, ModelScreen) + editor = app.screen.query_one("#chat-config-yaml", TextArea) + editor.text = """\ +model: gpt-5.4 +model_provider: openai +base_url: http://foo +api_key: + env: +""" + + await pilot.pause() + assert not editor.has_class("yaml-valid", "yaml-invalid") + await wait_for_yaml_debounce(pilot) + + assert isinstance(app.screen, ModelScreen) + app.screen.query_one("#chat-advanced", Collapsible).collapsed = False + await pilot.pause() + assert editor.has_class("yaml-invalid") + error = app.screen.query_one("#chat-yaml-error", Static) + assert "validation errors for ChatModelConfig" in str(error.content) + assert "api_key:" in str(error.content) + assert "errors.pydantic.dev" not in str(error.content) + assert error.styles.text_wrap == "wrap" + assert error.region.height > 3 + assert error.virtual_size.width <= error.content_region.width + + +async def test_yaml_validation_lists_all_errors_in_bounded_scroll_area( + tmp_path, monkeypatch +): + monkeypatch.setattr( + "ursa.cli.tui.widgets.list_provider_models", lambda _config: [] + ) + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test(size=(80, 30)) as pilot: + await app._show_command("models") + await pilot.pause() + await app.workers.wait_for_complete() + assert isinstance(app.screen, ModelScreen) + app.screen.query_one("#chat-advanced", Collapsible).collapsed = False + editor = app.screen.query_one("#chat-config-yaml", TextArea) + editor.text = """\ +model: gpt-5.4 +model_provider: openai +inference_provider: openai +max_completion_tokens: nope +ssl_verify: also-nope +api_key: + env: +""" + + await pilot.pause() + assert not editor.has_class("yaml-valid", "yaml-invalid") + await wait_for_yaml_debounce(pilot) + + error = app.screen.query_one("#chat-yaml-error", Static) + message = str(error.content) + assert "max_completion_tokens:" in message + assert "ssl_verify:" in message + assert "api_key:" in message + assert message.startswith("4 validation errors for ChatModelConfig") + assert len(message.splitlines()) == 5 + assert error.region.height == 6 + assert error.styles.overflow_y == "auto" + + +async def test_model_change_only_reports_changed_embedding(tmp_path): + hitl = FakeHITL(tmp_path) + app = UrsaTextualApp(hitl) + new_embedding = hitl.config.emb_model.model_copy( + update={"model": "text-embedding-3-small"} + ) + + async with app.run_test(size=(80, 24)): + app._select_model( + ModelSelection( + chat=hitl.config.llm_model.model_copy(), + embedding=new_embedding, + ) + ) + await app.workers.wait_for_complete() + + messages = [message.content for message in app.query(ToolMessage)] + assert not any( + "Changed the chat model" in message for message in messages + ) + assert messages[-1].startswith( + "Changed the embedding model to text-embedding-3-small" + ) + + +async def test_model_modal_preserves_direct_embedding_endpoint( + tmp_path, monkeypatch +): + hitl = FakeHITL(tmp_path) + hitl.config.emb_model = EmbModelConfig( + model="old-embedding", + model_provider="openai", + base_url="https://embeddings.example/v1", + check_embedding_ctx_length=False, + ) + monkeypatch.setattr( + "ursa.cli.tui.widgets.list_provider_models", lambda _config: [] + ) + app = UrsaTextualApp(hitl) + + async with app.run_test(size=(80, 24)) as pilot: + await app._show_command("models") + await pilot.pause() + await app.workers.wait_for_complete() + assert isinstance(app.screen, ModelScreen) + assert ( + app.screen.query_one("#embedding-inference-provider", Select).value + == ModelScreen.NONE_VALUE + ) + app.screen.query_one( + "#embedding-model-name", Select + ).value = ModelScreen.CUSTOM_VALUE + app.screen.query_one( + "#embedding-model-name-custom", Input + ).value = "new-embedding" + + embedding = app.screen._settings("embedding", app.screen.embedding) + + assert isinstance(embedding, EmbModelConfig) + assert embedding.model == "new-embedding" + assert embedding.inference_provider is None + assert embedding.base_url == "https://embeddings.example/v1" + assert embedding.check_embedding_ctx_length is False + + +async def test_switching_direct_endpoint_to_named_provider_stays_in_sync( + tmp_path, monkeypatch +): + hitl = FakeHITL(tmp_path) + hitl.config.llm_model = ChatModelConfig( + model="direct-model", + model_provider="openai", + base_url="https://direct.example/v1", + ) + discovered_with = [] + + def provider_models(config): + discovered_with.append(config) + return [ProviderModel("provider-model", "openai")] + + monkeypatch.setattr( + "ursa.cli.tui.widgets.list_provider_models", provider_models + ) + app = UrsaTextualApp(hitl) + + async with app.run_test(size=(80, 24)) as pilot: + await app._show_command("models") + await pilot.pause() + await app.workers.wait_for_complete() + assert isinstance(app.screen, ModelScreen) + + provider = app.screen.query_one("#chat-inference-provider", Select) + provider.value = "openai" + await pilot.pause() + await app.workers.wait_for_complete() + + draft = app.screen.drafts["chat"] + yaml_values = yaml.safe_load( + app.screen.query_one("#chat-config-yaml", TextArea).text + ) + assert provider.value == "openai" + assert draft.inference_provider == "openai" + assert draft.base_url is None + assert yaml_values["inference_provider"] == "openai" + assert "base_url" not in yaml_values + assert any( + config.base_url + == hitl.config.inference_providers["openai"].base_url + for config in discovered_with + ) + assert any( + model.name == "provider-model" + for model in app.screen.model_catalogs["chat"].values() + ) + + +async def test_named_provider_control_removes_direct_url_in_one_sync( + tmp_path, monkeypatch +): + hitl = FakeHITL(tmp_path) + hitl.config.llm_model = ChatModelConfig( + model="direct-model", + base_url="https://direct.example/v1", + ) + monkeypatch.setattr( + "ursa.cli.tui.widgets.list_provider_models", lambda _config: [] + ) + app = UrsaTextualApp(hitl) + + async with app.run_test(size=(80, 24)) as pilot: + await app._show_command("models") + await pilot.pause() + await app.workers.wait_for_complete() + assert isinstance(app.screen, ModelScreen) + provider = app.screen.query_one("#chat-inference-provider", Select) + initial_yaml = yaml.safe_load( + app.screen.query_one("#chat-config-yaml", TextArea).text + ) + assert initial_yaml["base_url"] == "https://direct.example/v1" + app.screen._syncing_controls = True + try: + provider.value = "openai" + await pilot.pause() + finally: + app.screen._syncing_controls = False + + app.screen._structured_controls_changed("chat") + + yaml_values = yaml.safe_load( + app.screen.query_one("#chat-config-yaml", TextArea).text + ) + assert app.screen.drafts["chat"].inference_provider == "openai" + assert "base_url" not in yaml_values + + +async def test_immediate_apply_clears_direct_url_for_named_provider( + tmp_path, monkeypatch +): + hitl = FakeHITL(tmp_path) + hitl.config.llm_model = ChatModelConfig( + model="direct-model", + base_url="https://direct.example/v1", + temperature=0.2, + ) + monkeypatch.setattr( + "ursa.cli.tui.widgets.list_provider_models", lambda _config: [] + ) + app = UrsaTextualApp(hitl) + + async with app.run_test(size=(80, 24)) as pilot: + await app._show_command("models") + await pilot.pause() + await app.workers.wait_for_complete() + assert isinstance(app.screen, ModelScreen) + app.screen.query_one( + "#chat-inference-provider", Select + ).value = "openai" + + app.screen.action_apply() + await pilot.pause() + + assert not isinstance(app.screen, ModelScreen) + assert hitl.config.llm_model.inference_provider == "openai" + assert ( + hitl.config.llm_model.base_url + == hitl.config.inference_providers["openai"].base_url + ) + assert hitl.config.llm_model.base_url != "https://direct.example/v1" + assert hitl.config.llm_model.model_extra == {"temperature": 0.2} + + +async def test_model_modal_preserves_only_explicit_overrides_when_switching_provider( + tmp_path, monkeypatch +): + hitl = FakeHITL(tmp_path) + hitl.config.inference_providers["other"] = InferenceProviderConfig( + base_url="https://other.example/v1", + ssl_verify=False, + timeout=20, + ) + configured = ChatModelConfig( + model="gpt-test", + inference_provider="openai", + ssl_verify=False, + temperature=0.2, + ) + openai = hitl.config.inference_providers["openai"] + hitl.config.inference_providers["openai"] = InferenceProviderConfig( + base_url=openai.base_url, + api_key=openai.api_key, + timeout=10, + ) + hitl.config.llm_model = configured.resolve_inference_provider( + hitl.config.inference_providers + ) + monkeypatch.setattr( + "ursa.cli.tui.widgets.list_provider_models", + lambda _config: [ProviderModel("gpt-test", "openai")], + ) + app = UrsaTextualApp(hitl) + + async with app.run_test(size=(80, 24)) as pilot: + await app._show_command("models") + await pilot.pause() + await app.workers.wait_for_complete() + assert isinstance(app.screen, ModelScreen) + app.screen.query_one("#chat-inference-provider", Select).value = "other" + + chat = app.screen._settings("chat", app.screen.chat) + + assert isinstance(chat, ChatModelConfig) + assert chat.inference_provider == "other" + assert chat.ssl_verify is False + assert chat.model_extra == {"temperature": 0.2} + + +async def test_model_modal_uses_default_provider_for_new_embedding( + tmp_path, monkeypatch +): + hitl = FakeHITL(tmp_path) + hitl.config.emb_model = None + discovered_with = [] + + def provider_models(config): + discovered_with.append(config) + return [ProviderModel("text-embedding-test", "openai", "embedding")] + + monkeypatch.setattr( + "ursa.cli.tui.widgets.list_provider_models", provider_models + ) + app = UrsaTextualApp(hitl) + + async with app.run_test(size=(80, 24)) as pilot: + await app._show_command("models") + await pilot.pause() + await app.workers.wait_for_complete() + assert isinstance(app.screen, ModelScreen) + assert any( + config.model_provider == "openai" + and config.api_key + == hitl.config.inference_providers["openai"].api_key + for config in discovered_with + ) + assert ( + app.screen.query_one("#embedding-inference-provider", Select).value + == "openai" + ) + app.screen.query_one( + "#embedding-model-name", Select + ).value = "text-embedding-test" + app.screen.query_one( + "#embedding-model-provider", Select + ).value = ModelScreen.NONE_VALUE + + embedding = app.screen._settings("embedding", app.screen.embedding) + + assert isinstance(embedding, EmbModelConfig) + assert embedding.model_provider == "openai" + assert embedding.inference_provider == "openai" + + +async def test_stale_model_discovery_cannot_replace_new_provider_catalog( + tmp_path, monkeypatch +): + hitl = FakeHITL(tmp_path) + hitl.config.inference_providers["fast"] = InferenceProviderConfig( + base_url="https://fast.example/v1" + ) + slow_started = Event() + release_slow = Event() + replacement_requested = Event() + publications_after_request = [] + + def provider_models(config): + if config.base_url == "https://api.openai.com/v1": + slow_started.set() + assert release_slow.wait(timeout=5) + return [ProviderModel("gpt-5.4", "openai")] + return [ProviderModel("fast-only", "openai")] + + monkeypatch.setattr( + "ursa.cli.tui.widgets.list_provider_models", provider_models + ) + app = UrsaTextualApp(hitl) + + async with app.run_test(size=(80, 24)) as pilot: + await app._show_command("models") + assert await asyncio.to_thread(slow_started.wait, 5) + assert isinstance(app.screen, ModelScreen) + initial_workers = [ + worker + for worker in app.workers + if worker.group == "model-discovery" + ] + original_set_options = app.screen._set_model_options + + def record_options(prefix, catalog, current): + if replacement_requested.is_set() and prefix == "chat": + publications_after_request.append(set(catalog)) + original_set_options(prefix, catalog, current) + + monkeypatch.setattr(app.screen, "_set_model_options", record_options) + replacement_requested.set() + replacement_worker = app.screen._request_model_load( + "chat", hitl.config.inference_providers["fast"] + ) + release_slow.set() + await replacement_worker.wait() + await asyncio.gather(*(worker.wait() for worker in initial_workers)) + await pilot.pause() + + model_select = app.screen.query_one("#chat-model-name", Select) + assert app.screen.model_catalogs["chat"] == { + "fast-only": ProviderModel("fast-only", "openai") + } + assert model_select.value == "test-model" + assert ("Not found: test-model", "test-model") in model_select._options + assert ("gpt-5.4", "gpt-5.4") not in model_select._options + assert all( + "gpt-5.4" not in catalog for catalog in publications_after_request + ) + assert app.screen.query_one("#chat-model-name-custom", Input).has_class( + "hidden" + ) + + +async def test_model_modal_seeded_provider_fuzz_preserves_invariants( + tmp_path, monkeypatch +): + hitl = FakeHITL(tmp_path) + for index in range(3): + hitl.config.inference_providers[f"provider-{index}"] = ( + InferenceProviderConfig( + base_url=f"https://provider-{index}.example/v1" + ) + ) + + def provider_models(config): + host = config.base_url or "" + provider_index = next( + (index for index in range(3) if f"provider-{index}" in host), + 9, + ) + return [ + ProviderModel(f"model-{provider_index}-{model_index}", "openai") + for model_index in range(3) + ] + + monkeypatch.setattr( + "ursa.cli.tui.widgets.list_provider_models", provider_models + ) + app = UrsaTextualApp(hitl) + choices = [f"provider-{index}" for index in range(3)] + rng = random.Random(20260826) + + async with app.run_test(size=(80, 24)) as pilot: + await app._show_command("models") + await pilot.pause() + await app.workers.wait_for_complete() + assert isinstance(app.screen, ModelScreen) + + for iteration in range(30): + provider_select = app.screen.query_one( + "#chat-inference-provider", Select + ) + provider = rng.choice([ + choice for choice in choices if choice != provider_select.value + ]) + provider_index = int(provider.rsplit("-", 1)[1]) + model_select = app.screen.query_one("#chat-model-name", Select) + custom = app.screen.query_one("#chat-model-name-custom", Input) + if rng.random() < 0.25: + model_select.value = ModelScreen.CUSTOM_VALUE + custom.value = f"custom-{iteration}" + await pilot.pause() + assert not custom.has_class("hidden") + elif rng.random() < 0.5: + model_select.value = rng.choice([ + value + for _label, value in model_select._options + if isinstance(value, str) + and value + not in {ModelScreen.NONE_VALUE, ModelScreen.CUSTOM_VALUE} + ]) + provider_select.value = provider + await pilot.pause() + await app.workers.wait_for_complete() + + selected = str(model_select.value) + options = dict( + (value, label) for label, value in model_select._options + ) + available = { + f"model-{provider_index}-{model_index}" + for model_index in range(3) + } + assert selected in options + if selected not in available: + assert options[selected] == f"Not found: {selected}" + assert custom.has_class("hidden") + assert app.screen.drafts["chat"].model == selected + assert app.screen.drafts["chat"].inference_provider == provider + yaml_values = yaml.safe_load( + app.screen.query_one("#chat-config-yaml", TextArea).text + ) + assert yaml_values["inference_provider"] == provider + + +def test_model_modal_new_embedding_inherits_direct_chat_settings(tmp_path): + hitl = FakeHITL(tmp_path) + chat = ChatModelConfig( + model="chat-model", + model_provider="openai", + base_url="https://gateway.example/v1", + api_key={"env": "GATEWAY_API_KEY"}, + ssl_verify=False, + ) + + screen = ModelScreen(hitl.config.inference_providers, chat, None) + + assert screen.embedding.model_provider == "openai" + assert screen.embedding.inference_provider is None + assert screen.embedding.base_url == "https://gateway.example/v1" + assert screen.embedding.api_key == chat.api_key + assert screen.embedding.ssl_verify is False + + +async def test_command_picker_prioritizes_command_name_over_description( + tmp_path, +): + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test(size=(80, 24)) as pilot: + await pilot.press("/", "k", "e") + await pilot.pause() + + assert isinstance(app.screen, HotlistScreen) + assert app.screen.matches[0].startswith("keymap —") + assert any(match.startswith("status —") for match in app.screen.matches) + assert app.screen.query_one("#hotlist-options").highlighted == 0 + + +async def test_theme_command_selects_theme_and_escape_preserves_it(tmp_path): + app = UrsaTextualApp(FakeHITL(tmp_path)) + + async with app.run_test(size=(80, 24)) as pilot: + status = app.query_one("#status", Static) + dark_background = status.styles.background + assert app.theme == "ursa-dark" + assert app.get_theme("ursa-dark").dark + assert not app.get_theme("ursa-light").dark + + await pilot.press("/") + await pilot.pause() + await pilot.press("t", "h", "e", "m", "e", "enter") + await pilot.pause() + + assert isinstance(app.screen, ThemeScreen) + assert app.screen.styles.background.a == 0 + assert app.screen.picker_title == "Themes" + assert app.screen.candidates[:2] == ["ursa-dark", "ursa-light"] + assert set(BUILTIN_THEMES) <= set(app.screen.candidates) + await pilot.press("down") + await pilot.pause() + + assert app.theme == "ursa-light" + assert status.styles.background != dark_background + await pilot.press("up") + await pilot.pause() + assert app.theme == "ursa-dark" + assert status.styles.background == dark_background + + await pilot.press("down", "enter") + await pilot.pause() + + assert app.theme == "ursa-light" + assert status.styles.background != dark_background + assert "| Theme | `ursa-light` |" in app._status_markdown() + + await app._show_command("theme") + await pilot.pause() + assert app.screen.candidates[:2] == ["ursa-light", "ursa-dark"] + await pilot.press("down") + await pilot.pause() + assert app.theme == "ursa-dark" + await pilot.press("escape") + await pilot.pause() + + assert app.theme == "ursa-light" + assert app.query_one(PromptArea).has_focus + + await app._show_command("theme") + await pilot.pause() + app.screen.query_one(Input).value = "nord" + await pilot.pause() + assert app.theme == "nord" + await pilot.press("escape") + await pilot.pause() + assert app.theme == "ursa-light" + + +async def test_agents_command_uses_tabs_and_collapsed_tool_details(tmp_path): + hitl = FakeHITL(tmp_path) + hitl.agents = { + "plan": hitl.agents["plan"], + "chat": hitl.agents["chat"], + } + for agent in hitl.agents.values(): + agent.tools = { + "read_file": FakeConfiguredTool(), + "remote_read": FakeMcpTool(), + } + agent.tool_sources = {"remote_read": "laboratory"} + + async def get_agent(name): + return hitl.agents[name] + + hitl.get_agent = get_agent + app = UrsaTextualApp(hitl) + + async with app.run_test(size=(100, 36)) as pilot: + await pilot.press("/", "a", "g", "e", "n", "t", "s", "enter") + await pilot.pause() + await app.workers.wait_for_complete() + await pilot.pause() + + assert isinstance(app.screen, AgentsScreen) + panes = list(app.screen.query(TabPane)) + assert len(panes) == 2 + assert [tab.label_text for tab in app.screen.query(Tab)] == [ + "#plan", + "#chat", + ] + tools = list(app.screen.query(Collapsible)) + assert len(tools) == 2 + assert all(tool.collapsed for tool in tools) + assert [str(tool.title) for tool in tools[:2]] == [ + "read_file", + "remote_read (mcp: laboratory)", + ] + + tools[0].collapsed = False + await pilot.pause() + detail = str(tools[0].query_one(Markdown).source) + assert "Read a file from the configured workspace." in detail + assert "FakeConfiguredTool" in detail + assert "FakeToolArgs" in detail + assert "Workspace-relative file path." in detail + tools[0].collapsed = True + await pilot.pause() + assert tools[0].collapsed + tools[0].collapsed = False + await pilot.pause() + assert len(tools[0].query(Markdown)) == 1 + + await pilot.press("right") + await pilot.pause() + await app.workers.wait_for_complete() + await pilot.pause() + assert len(app.screen.query(Collapsible)) == 4 + + +async def test_agents_lazily_load_tools_and_only_once(tmp_path): + hitl = FakeHITL(tmp_path) + ready = asyncio.Event() + calls = [] + wrappers = { + name: SimpleNamespace( + description=f"{name} agent", + config={}, + tool_sources={}, + _agent=None, + ) + for name in ("plan", "chat") + } + hitl.agents = wrappers + + async def get_agent(name): + calls.append(name) + if name == "plan": + await ready.wait() + wrapper = wrappers[name] + wrapper._agent = SimpleNamespace( + tools={"read_file": FakeConfiguredTool()} + ) + return wrapper + + hitl.get_agent = get_agent + app = UrsaTextualApp(hitl) + + async with app.run_test(size=(100, 36)) as pilot: + await app._show_command("agents") + await pilot.pause() + + assert isinstance(app.screen, AgentsScreen) + assert calls == ["plan"] + # A callback already queued when hydration stops must tolerate the + # frame state being gone while the loading node is still mounted. + app.screen._stop_tool_loading(0) + app.screen._advance_tool_loading(0) + # Tool discovery is suspended, but tabs remain interactive. + await pilot.press("right") + await pilot.pause() + assert calls == ["plan", "chat"] + assert len(app.screen.query("#agent-tools-1 .agent-tool")) == 1 + + await pilot.press("left", "right") + await pilot.pause() + assert calls == ["plan", "chat"] + + ready.set() + await app.workers.wait_for_complete() + await pilot.pause() + assert app.screen.agents[0].tools_loaded + # Hydration of the hidden plan tab updates state without mounting its + # potentially expensive Markdown tool cards on the UI thread. + assert not app.screen.query("#agent-tools-0 .agent-tool") + assert app.screen._tool_panes_pending_render == {0} + + await pilot.press("left") + await pilot.pause() + assert len(app.screen.query("#agent-tools-0 .agent-tool")) == 1 + assert app.screen._tool_panes_pending_render == set() + + await pilot.press("right", "left") + await pilot.pause() + assert calls == ["plan", "chat"] + + +async def test_immediate_switch_preempts_large_tool_render( + tmp_path, monkeypatch +): + hydration_started = asyncio.Event() + hydration_release = asyncio.Event() + construction_started = asyncio.Event() + loading_seen_during_construction = False + construction_count = 0 + safe = SimpleNamespace( + description="safe agent", + config={}, + tool_sources={}, + _agent=SimpleNamespace(tools={}), + ) + execute = SimpleNamespace( + description="execution agent", + config={}, + tool_sources={}, + _agent=None, + ) + hitl = FakeHITL(tmp_path) + hitl.agents = {"safe": safe, "execute": execute} + + async def get_agent(name): + wrapper = hitl.agents[name] + if name == "execute": + hydration_started.set() + await hydration_release.wait() + wrapper._agent = SimpleNamespace( + tools={ + f"tool_{index}": SimpleNamespace( + name=f"tool_{index}", + description="A detailed configured tool. " * 20, + args_schema=FakeToolArgs, + return_direct=False, + ) + for index in range(100) + } + ) + return wrapper + + from ursa.cli.tui.widgets import AgentToolDetails + + def deliberately_slow_card(tool): + nonlocal construction_count, loading_seen_during_construction + construction_count += 1 + construction_started.set() + loading_seen_during_construction = bool( + app.screen.query("#agent-tools-1 .agent-tools-loading") + ) + time.sleep(0.01) + return AgentToolDetails(tool) + + monkeypatch.setattr( + "ursa.cli.tui.widgets.AgentToolDetails", deliberately_slow_card + ) + hitl.get_agent = get_agent + app = UrsaTextualApp(hitl) + + async with app.run_test(size=(100, 36)) as pilot: + await app._show_command("agents") + await app.workers.wait_for_complete() + await pilot.press("right") + await hydration_started.wait() + + ticks = 0 + + def heartbeat(): + nonlocal ticks + ticks += 1 + + timer = app.screen.set_interval(0.01, heartbeat) + ticks_before_release = ticks + hydration_release.set() + await construction_started.wait() + assert loading_seen_during_construction + # This is the reported ordering: completion becomes runnable just as + # the user asks to leave the expensive tab. + switch_task = asyncio.create_task(pilot.press("left")) + tabs = app.screen.query_one("#agents-tabs", TabbedContent) + async with asyncio.timeout(0.2): + while tabs.active != "agent-tab-0": + await asyncio.sleep(0.01) + await asyncio.sleep(0.05) + timer.stop() + + assert tabs.active == "agent-tab-0" + assert ticks > ticks_before_release + + await app.workers.wait_for_complete() + await switch_task + constructions_after_switch = construction_count + assert constructions_after_switch <= 5 + await asyncio.sleep(0.05) + assert construction_count == constructions_after_switch + assert len(app.screen.query("#agent-tools-1 .agent-tool")) < 100 + assert not app.screen.query("#agent-tools-1 .agent-tool Markdown") + + await pilot.press("right") + await pilot.pause() + await app.workers.wait_for_complete() + await pilot.pause() + assert len(app.screen.query("#agent-tools-1 .agent-tool")) == 100 + assert not app.screen.query("#agent-tools-1 .agent-tool Markdown") + assert not app.screen.query("#agent-tools-1 .agent-tools-loading") + + +async def test_agent_tool_render_failure_is_displayed(tmp_path, monkeypatch): + construction_attempts = 0 + safe = SimpleNamespace( + description="safe agent", + config={}, + tool_sources={}, + _agent=SimpleNamespace(tools={}), + ) + broken_tool = FakeConfiguredTool() + broken = SimpleNamespace( + description="broken agent", + config={}, + tool_sources={}, + _agent=SimpleNamespace(tools={broken_tool.name: broken_tool}), + ) + hitl = FakeHITL(tmp_path) + hitl.agents = {"safe": safe, "broken": broken} + + async def get_agent(name): + return hitl.agents[name] + + def fail_to_build_card(_tool): + nonlocal construction_attempts + construction_attempts += 1 + raise RuntimeError("tool card could not be built") + + monkeypatch.setattr( + "ursa.cli.tui.widgets.AgentToolDetails", fail_to_build_card + ) + hitl.get_agent = get_agent + app = UrsaTextualApp(hitl) + + async with app.run_test(size=(100, 36)) as pilot: + await app._show_command("agents") + await app.workers.wait_for_complete() + await pilot.press("right") + await pilot.pause() + await app.workers.wait_for_complete() + + error = app.screen.query_one( + "#agent-tools-1 .agent-tools-error", Static + ) + assert "tool card could not be built" in str(error.render()) + assert not app.screen.query("#agent-tools-1 .agent-tools-loading") + assert construction_attempts == 1 + assert 1 not in app.screen._tool_panes_pending_render + + await pilot.press("left") + await pilot.pause() + assert app.screen.query_one("#agents-tabs", TabbedContent).active == ( + "agent-tab-0" + ) + + await pilot.press("right") + await pilot.pause() + await app.workers.wait_for_complete() + assert construction_attempts == 1 + assert "tool card could not be built" in str(error.render()) + + +async def test_initialized_tools_render_while_schema_hydration_is_pending( + tmp_path, +): + schema_started = threading.Event() + schema_release = threading.Event() + + class BlockingSchema: + @classmethod + def model_json_schema(cls): + schema_started.set() + schema_release.wait(timeout=5) + return {"properties": {}} + + configured_tool = FakeConfiguredTool() + configured_tool.args_schema = BlockingSchema + wrapper = SimpleNamespace( + description="ready agent", + config={}, + tool_sources={}, + _agent=SimpleNamespace(tools={"read_file": configured_tool}), + ) + hitl = FakeHITL(tmp_path) + hitl.agents = {"ready": wrapper} + + async def get_agent(_name): + return wrapper + + hitl.get_agent = get_agent + app = UrsaTextualApp(hitl) + + try: + async with app.run_test(size=(100, 36)) as pilot: + await app._show_command("agents") + assert await asyncio.to_thread(schema_started.wait, 2) + assert app.screen.query(".agent-tools-loading") + tools = app.screen.query("#agent-tools-0 .agent-tool") + assert len(tools) == 1 + assert "read_file" in str(tools.first(Collapsible).title) + + schema_release.set() + await app.workers.wait_for_complete() + await pilot.pause() + assert len(app.screen.query("#agent-tools-0 .agent-tool")) == 1 + finally: + schema_release.set() + + +async def test_agents_display_tool_load_failure(tmp_path): + hitl = FakeHITL(tmp_path) + wrapper = SimpleNamespace( + description="broken agent", + config={}, + tool_sources={}, + _agent=None, + ) + hitl.agents = {"broken": wrapper} + + async def get_agent(name): + raise RuntimeError("MCP server unavailable") + + hitl.get_agent = get_agent + app = UrsaTextualApp(hitl) + + async with app.run_test(size=(100, 36)) as pilot: + await app._show_command("agents") + await pilot.pause() + await app.workers.wait_for_complete() + await pilot.pause() + + error = app.screen.query_one(".agent-tools-error", Static) + assert "MCP server unavailable" in str(error.render()) + assert not app.screen.query(".agent-tools-loading") + assert app.screen._tool_loading_timers == {} + assert app.screen._tool_loading_frames == {} + assert isinstance(app.screen, AgentsScreen) + + +async def test_agents_remain_responsive_during_blocking_initialization( + tmp_path, chat_model +): + constructor_started = threading.Event() + constructor_release = threading.Event() + mcp_started = threading.Event() + mcp_release = threading.Event() + schema_started = threading.Event() + schema_release = threading.Event() + + class BlockingSchema: + @classmethod + def model_json_schema(cls): + schema_started.set() + schema_release.wait(timeout=5) + return {"properties": {}} + + configured_tool = FakeConfiguredTool() + configured_tool.args_schema = BlockingSchema + + class PhasedAgent(AgentWithTools): + """A deliberately long description used to make this pane scroll. + + The remaining text creates enough vertical content to exercise scroll + input while constructor, MCP, and schema phases are independently held. + """ + + def __init__(self, **_kwargs): + constructor_started.set() + constructor_release.wait(timeout=5) + self._test_tools = {} + + @property + def tools(self): + return self._test_tools + + async def add_mcp_tools(self, _client): + mcp_started.set() + await asyncio.to_thread(mcp_release.wait, 5) + self._test_tools = {configured_tool.name: configured_tool} + return {configured_tool.name: "laboratory"} + + hitl = FakeHITL(tmp_path) + execute = AgentHITL(agent_class=PhasedAgent) + execute.config.update({f"option_{index}": index for index in range(20)}) + initialized = AgentHITL(agent_class=ExecutionAgent) + initialized._agent = SimpleNamespace(tools={}) + hitl.agents = {"execute": execute, "ready": initialized} + + async def get_agent(name): + wrapper = hitl.agents[name] + if wrapper._agent is None: + await wrapper.instantiate( + llm=chat_model, + workspace=tmp_path, + agent_name="persistent", + group="default", + mcp_client=object(), + thread_id="test", + ) + return wrapper + + hitl.get_agent = get_agent + app = UrsaTextualApp(hitl) + + async def assert_ui_is_live(pilot, screen): + loading = screen.query_one(".agent-tools-loading", Static) + first_frame = str(loading.render()) + await asyncio.sleep(0.35) + await pilot.pause() + assert str(loading.render()) != first_frame + await pilot.press("right") + assert screen.query_one("#agents-tabs", TabbedContent).active == ( + "agent-tab-1" + ) + await pilot.press("left") + scroll = screen._scroll_view() + await pilot.press("end") + await pilot.pause() + bottom = scroll.scroll_y + assert bottom > 0 + await pilot.press("home") + await pilot.pause() + assert scroll.scroll_y < bottom + + try: + async with app.run_test(size=(100, 36)) as pilot: + await app._show_command("agents") + assert await asyncio.to_thread(constructor_started.wait, 2) + screen = app.screen + await assert_ui_is_live(pilot, screen) + + constructor_release.set() + assert await asyncio.to_thread(mcp_started.wait, 2) + await assert_ui_is_live(pilot, screen) + + mcp_release.set() + assert await asyncio.to_thread(schema_started.wait, 2) + await assert_ui_is_live(pilot, screen) + + schema_release.set() + await app.workers.wait_for_complete() + await pilot.pause() + assert screen.query("#agent-tools-0 .agent-tool") + assert screen._tool_loading_timers == {} + assert screen._tool_loading_frames == {} + finally: + constructor_release.set() + mcp_release.set() + schema_release.set() + + +async def test_dismissing_agents_during_loading_cleans_up_and_publishes( + tmp_path, +): + schema_started = threading.Event() + schema_release = threading.Event() + + class BlockingSchema: + @classmethod + def model_json_schema(cls): + schema_started.set() + schema_release.wait(timeout=5) + return {"properties": {}} + + configured_tool = FakeConfiguredTool() + configured_tool.args_schema = BlockingSchema + + class SlowAgent: + description = "Slow agent" + + def __init__(self, **_kwargs): + self.tools = {"read_file": configured_tool} + + hitl = FakeHITL(tmp_path) + wrapper = AgentHITL(agent_class=SlowAgent) + hitl.agents = {"slow": wrapper} + + async def get_agent(_name): + await wrapper.instantiate() + return wrapper + + hitl.get_agent = get_agent + app = UrsaTextualApp(hitl) + + try: + async with app.run_test(size=(100, 36)) as pilot: + await app._show_command("agents") + assert await asyncio.to_thread(schema_started.wait, 2) + loading_screen = app.screen + await pilot.press("escape") + await pilot.pause() + assert not isinstance(app.screen, AgentsScreen) + assert loading_screen._tool_loading_timers == {} + assert loading_screen._tool_loading_frames == {} + + schema_release.set() + await wrapper.wait_until_initialized() + await app._show_command("agents") + await pilot.pause() + assert not app.screen.query(".agent-tools-loading") + assert app.screen.query("#agent-tools-0 .agent-tool") + finally: + schema_release.set() + + +async def test_agents_lazily_render_execution_agent_tools(tmp_path, chat_model): + hitl = FakeHITL(tmp_path) + wrapper = AgentHITL(agent_class=ExecutionAgent) + hitl.agents = {"execute": wrapper} + + async def get_agent(name): + if wrapper._agent is None: + await wrapper.instantiate( + llm=chat_model, + workspace=tmp_path, + agent_name=None, + group="default", + mcp_client=None, + thread_id="test", + ) + return wrapper + + hitl.get_agent = get_agent + app = UrsaTextualApp(hitl) + + async with app.run_test(size=(100, 36)) as pilot: + await app._show_command("agents") + await pilot.pause() + await app.workers.wait_for_complete() + await pilot.pause() + + rendered_tools = app.screen.query("#agent-tools-0 .agent-tool") + assert len(rendered_tools) == len(wrapper._agent.tools) + assert len(rendered_tools) > 0 + first_tool = rendered_tools.first() + tools_container = app.screen.query_one("#agent-tools-0", Vertical) + details = app.screen.query_one(".agent-details", VerticalScroll) + children = list(details.children) + tools_title = app.screen.query_one(".agent-tools-title", Static) + title_index = children.index(tools_title) + assert all( + children.index(markdown) < title_index + for markdown in details.query(Markdown) + if markdown.parent is details + ) + assert children.index(tools_title) < children.index(tools_container) + app.screen.refresh(layout=True) + await pilot.pause() + assert first_tool.region.height > 0 + assert tools_container.region.contains_region(first_tool.region) + assert first_tool.region.y < app.screen.region.bottom + + +def test_command_details_and_keymap_come_from_live_bindings( + tmp_path, monkeypatch +): + monkeypatch.setattr(crossplatform, "expects_kitty_keyboard", lambda: False) + app = UrsaTextualApp(FakeHITL(tmp_path)) + app.total_tokens = 1234 + app.input_tokens = 1000 + app.output_tokens = 234 + app.cached_tokens = 456 + monkeypatch.setattr( + PromptArea, + "BINDINGS", + [ + *PromptArea.BINDINGS, + Binding("f12", "diagnostics", "Open diagnostics", show=False), + ], + ) + + status = app._status_markdown() + keymap = app._keymap_markdown() + + assert "1,234" in status + assert "1,000" in status + assert "234" in status + assert "456" in status + assert "test-model" in status + assert "Kitty keyboard support" in keymap + assert "not identified" in keymap + assert "shift+⏎ / ^j" in keymap + assert "^c" in keymap + assert "Clear prompt" in keymap + for expected in ( + "## Application", + "## Prompt editor", + "## Picker", + "## Information screen", + "Submit prompt", + "Choose workspace path", + "Previous choice", + "Scroll to bottom", + "Open diagnostics", + ): + assert expected in keymap + + +def test_keymap_omits_compatibility_warning_when_kitty_is_expected( + tmp_path, monkeypatch +): + monkeypatch.setattr(crossplatform, "expects_kitty_keyboard", lambda: True) + app = UrsaTextualApp(FakeHITL(tmp_path)) + + keymap = app._keymap_markdown() + + assert "Kitty keyboard support expected" in keymap + assert "may not work" not in keymap diff --git a/tests/conftest.py b/tests/conftest.py index 4a335784..24977461 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -116,10 +116,10 @@ def fake_init_embeddings(*args, **kwargs): "langchain.embeddings.init_embeddings", fake_init_embeddings ) monkeypatch.setattr( - "ursa.cli.hitl.init_chat_model", fake_init_chat_model, raising=False + "ursa.cli.runtime.init_chat_model", fake_init_chat_model, raising=False ) monkeypatch.setattr( - "ursa.cli.hitl.init_embeddings", fake_init_embeddings, raising=False + "ursa.cli.runtime.init_embeddings", fake_init_embeddings, raising=False ) monkeypatch.setattr( "ursa.cli.config.init_chat_model", diff --git a/tests/test_base_interface.py b/tests/test_base_interface.py index a35970b1..9569855c 100644 --- a/tests/test_base_interface.py +++ b/tests/test_base_interface.py @@ -1,5 +1,6 @@ import importlib import inspect +import threading from pathlib import Path from unittest.mock import AsyncMock @@ -217,6 +218,30 @@ async def test_agent_with_tools_add_mcp_tools_adds_all(chat_model, tmp_path): client.get_tools.assert_awaited_once() +@pytest.mark.asyncio +async def test_agent_with_tools_applies_mcp_tools_off_event_loop( + chat_model, tmp_path, monkeypatch +): + alpha = _make_tool("alpha") + client = AsyncMock() + client.get_tools.return_value = [alpha] + agent = DummyAgentWithTools(llm=chat_model, workspace=tmp_path) + event_loop_thread = threading.get_ident() + apply_threads = [] + original_add_tool = agent.add_tool + + def recording_add_tool(tools): + apply_threads.append(threading.get_ident()) + original_add_tool(tools) + + monkeypatch.setattr(agent, "add_tool", recording_add_tool) + + await agent.add_mcp_tools(client) + + assert apply_threads + assert apply_threads[0] != event_loop_thread + + @pytest.mark.asyncio async def test_agent_with_tools_add_mcp_tools_filters_by_name( chat_model, tmp_path @@ -232,6 +257,28 @@ async def test_agent_with_tools_add_mcp_tools_filters_by_name( assert agent.tools == {"beta": beta} +@pytest.mark.asyncio +async def test_agent_with_tools_returns_mcp_tool_provenance_without_mutation( + chat_model, tmp_path +): + alpha = _make_tool("alpha") + beta = _make_tool("beta") + client = AsyncMock() + client.connections = {"laboratory": {}, "archive": {}} + + async def get_tools(*, server_name): + return [alpha] if server_name == "laboratory" else [beta] + + client.get_tools.side_effect = get_tools + agent = DummyAgentWithTools(llm=chat_model, workspace=tmp_path) + + sources = await agent.add_mcp_tools(client) + + assert sources == {"alpha": "laboratory", "beta": "archive"} + assert agent.tools["alpha"].metadata is None + assert agent.tools["beta"].metadata is None + + def test_tool_runtime_preserved_for_tool_node(chat_model, tmp_path: Path): class WriteToolAgent(AgentWithTools, BaseAgent): def __init__(self, **kwargs): diff --git a/tests/tools/dummy_mcp_server.py b/tests/tools/dummy_mcp_server.py index 872c1fd0..5a00d268 100644 --- a/tests/tools/dummy_mcp_server.py +++ b/tests/tools/dummy_mcp_server.py @@ -5,8 +5,12 @@ # "fastmcp", # ] # /// +import sys + from fastmcp import FastMCP +print("dummy MCP diagnostic", file=sys.stderr) + mcp = FastMCP() diff --git a/tests/tools/test_search_tools.py b/tests/tools/test_search_tools.py new file mode 100644 index 00000000..74de2849 --- /dev/null +++ b/tests/tools/test_search_tools.py @@ -0,0 +1,57 @@ +from types import SimpleNamespace + +import pytest + +import ursa.tools.search_tools as search_tools + + +async def test_web_search_dispatches_progress_asynchronously( + tmp_path, monkeypatch +): + emitted = [] + + class FakeEvents: + def emit(self, *_args, **_kwargs): + pytest.fail( + "synchronous event dispatch can deadlock the Textual UI" + ) + + async def aemit(self, message, *, stage, **payload): + emitted.append((message, stage, payload)) + + class FakeWebSearchAgent: + def __init__(self, **_kwargs): + pass + + async def ainvoke(self, **_kwargs): + return {"final_summary": "A useful result"} + + monkeypatch.setattr( + search_tools.ToolEvents, + "from_runtime", + lambda *_args, **_kwargs: FakeEvents(), + ) + monkeypatch.setattr(search_tools, "WebSearchAgent", FakeWebSearchAgent) + runtime = SimpleNamespace( + context=SimpleNamespace(llm=object(), den=tmp_path) + ) + + result = await search_tools.run_web_search.coroutine( + prompt="Find evidence", + query="ursa", + runtime=runtime, + ) + + assert result == "[Web Search Agent Output]:\n A useful result" + assert emitted == [ + ( + "Searching Web", + "search", + {"query": "ursa", "max_results": 3}, + ), + ( + "Web search complete", + "search_result", + {"query": "ursa", "result_chars": 15}, + ), + ] diff --git a/tests/util/__init__.py b/tests/util/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/util/test_crossplatform.py b/tests/util/test_crossplatform.py new file mode 100644 index 00000000..06e8b23f --- /dev/null +++ b/tests/util/test_crossplatform.py @@ -0,0 +1,194 @@ +import ursa.util.crossplatform as crossplatform + + +def test_kitty_keyboard_support_uses_terminal_identity(monkeypatch): + monkeypatch.delenv("TMUX", raising=False) + monkeypatch.delenv("ZELLIJ", raising=False) + monkeypatch.setenv("TERM_PROGRAM", "ghostty") + + assert crossplatform.expects_kitty_keyboard() + + +def test_kitty_keyboard_support_fails_closed_for_multiplexers(monkeypatch): + monkeypatch.setenv("TERM_PROGRAM", "ghostty") + monkeypatch.setenv("TMUX", "/tmp/tmux-501/default,1,0") + + assert not crossplatform.expects_kitty_keyboard() + + +def test_kitty_keyboard_support_falls_back_to_terminfo_name(monkeypatch): + monkeypatch.delenv("TMUX", raising=False) + monkeypatch.delenv("ZELLIJ", raising=False) + monkeypatch.delenv("TERM_PROGRAM", raising=False) + for name in crossplatform.KITTY_KEYBOARD_ENV_VARS: + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("TERM", "xterm-kitty") + + assert crossplatform.expects_kitty_keyboard() + + +def test_kitty_keyboard_support_uses_vendor_session_identity(monkeypatch): + monkeypatch.delenv("TMUX", raising=False) + monkeypatch.delenv("ZELLIJ", raising=False) + monkeypatch.setenv("ALACRITTY_WINDOW_ID", "42") + + assert crossplatform.expects_kitty_keyboard() + + +def test_kitty_keyboard_support_fails_closed_for_unknown_terminal(monkeypatch): + for name in ( + *crossplatform.KITTY_KEYBOARD_ENV_VARS, + "TMUX", + "ZELLIJ", + "TERM_PROGRAM", + ): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("TERM", "xterm-256color") + + assert not crossplatform.expects_kitty_keyboard() + + +def test_copy_to_clipboard_runs_platform_tool(monkeypatch): + calls = [] + monkeypatch.setattr( + crossplatform, "platform_clipboard", lambda: ["fake-copy"] + ) + monkeypatch.setattr( + crossplatform.subprocess, + "run", + lambda *args, **kwargs: calls.append((args, kwargs)), + ) + + assert crossplatform.copy_to_clipboard("hello") + assert calls[0][0][0] == ["fake-copy"] + assert calls[0][1]["input"] == "hello" + assert calls[0][1]["timeout"] == 2 + + +def test_copy_to_clipboard_returns_false_without_tool(monkeypatch): + monkeypatch.setattr(crossplatform, "platform_clipboard", lambda: None) + + assert not crossplatform.copy_to_clipboard("hello") + + +def test_copy_to_clipboard_returns_false_on_failure(monkeypatch): + monkeypatch.setattr( + crossplatform, "platform_clipboard", lambda: ["fake-copy"] + ) + + def fail(*args, **kwargs): + raise crossplatform.subprocess.CalledProcessError(1, args[0]) + + monkeypatch.setattr(crossplatform.subprocess, "run", fail) + + assert not crossplatform.copy_to_clipboard("hello") + + +def test_platform_clipboard_uses_env_override(monkeypatch): + monkeypatch.setenv("URSA_CLIPBOARD", "/custom/clip --flag value") + + assert crossplatform.platform_clipboard() == [ + "/custom/clip", + "--flag", + "value", + ] + + +def test_platform_clipboard_prefers_macos_pbcopy(monkeypatch): + monkeypatch.delenv("URSA_CLIPBOARD", raising=False) + monkeypatch.delenv("SSH_CONNECTION", raising=False) + monkeypatch.delenv("SSH_CLIENT", raising=False) + monkeypatch.delenv("SSH_TTY", raising=False) + monkeypatch.setattr(crossplatform.sys, "platform", "darwin") + monkeypatch.setattr( + crossplatform.shutil, + "which", + lambda name: "/usr/bin/pbcopy" if name == "pbcopy" else None, + ) + + assert crossplatform.platform_clipboard() == ["pbcopy"] + + +def test_platform_clipboard_prefers_windows_clip(monkeypatch): + monkeypatch.delenv("URSA_CLIPBOARD", raising=False) + monkeypatch.delenv("SSH_CONNECTION", raising=False) + monkeypatch.delenv("SSH_CLIENT", raising=False) + monkeypatch.delenv("SSH_TTY", raising=False) + monkeypatch.setattr(crossplatform.sys, "platform", "win32") + monkeypatch.setattr( + crossplatform.shutil, + "which", + lambda name: "C:/Windows/System32/clip.exe" if name == "clip" else None, + ) + + assert crossplatform.platform_clipboard() == ["clip"] + + +def test_platform_clipboard_prefers_wayland(monkeypatch): + monkeypatch.delenv("URSA_CLIPBOARD", raising=False) + monkeypatch.delenv("SSH_CONNECTION", raising=False) + monkeypatch.delenv("SSH_CLIENT", raising=False) + monkeypatch.delenv("SSH_TTY", raising=False) + monkeypatch.setattr(crossplatform.sys, "platform", "linux") + monkeypatch.setenv("WAYLAND_DISPLAY", "wayland-0") + monkeypatch.delenv("DISPLAY", raising=False) + monkeypatch.setattr( + crossplatform.shutil, + "which", + lambda name: "/usr/bin/wl-copy" if name == "wl-copy" else None, + ) + + assert crossplatform.platform_clipboard() == ["wl-copy"] + + +def test_platform_clipboard_prefers_xclip_then_xsel(monkeypatch): + monkeypatch.delenv("URSA_CLIPBOARD", raising=False) + monkeypatch.delenv("SSH_CONNECTION", raising=False) + monkeypatch.delenv("SSH_CLIENT", raising=False) + monkeypatch.delenv("SSH_TTY", raising=False) + monkeypatch.setattr(crossplatform.sys, "platform", "linux") + monkeypatch.delenv("WAYLAND_DISPLAY", raising=False) + monkeypatch.setenv("DISPLAY", ":0") + monkeypatch.setattr( + crossplatform.shutil, + "which", + lambda name: "/usr/bin/xclip" if name == "xclip" else None, + ) + + assert crossplatform.platform_clipboard() == [ + "xclip", + "-selection", + "clipboard", + ] + + monkeypatch.setattr( + crossplatform.shutil, + "which", + lambda name: "/usr/bin/xsel" if name == "xsel" else None, + ) + + assert crossplatform.platform_clipboard() == [ + "xsel", + "--clipboard", + "--input", + ] + + +def test_platform_clipboard_returns_none_when_unavailable(monkeypatch): + monkeypatch.delenv("URSA_CLIPBOARD", raising=False) + monkeypatch.delenv("SSH_CONNECTION", raising=False) + monkeypatch.delenv("SSH_CLIENT", raising=False) + monkeypatch.delenv("SSH_TTY", raising=False) + monkeypatch.setattr(crossplatform.sys, "platform", "linux") + monkeypatch.delenv("WAYLAND_DISPLAY", raising=False) + monkeypatch.delenv("DISPLAY", raising=False) + monkeypatch.setattr(crossplatform.shutil, "which", lambda name: None) + + assert crossplatform.platform_clipboard() is None + + +def test_platform_clipboard_returns_none_in_ssh_session(monkeypatch): + monkeypatch.delenv("URSA_CLIPBOARD", raising=False) + monkeypatch.setenv("SSH_CONNECTION", "1 2 3 4") + + assert crossplatform.platform_clipboard() is None diff --git a/tests/util/test_inference_providers.py b/tests/util/test_inference_providers.py new file mode 100644 index 00000000..781125bb --- /dev/null +++ b/tests/util/test_inference_providers.py @@ -0,0 +1,414 @@ +from types import SimpleNamespace + +import pytest +from pydantic import SecretStr + +from ursa.cli.config import InferenceProviderConfig, ModelConfig +from ursa.util import inference_providers +from ursa.util.inference_providers import ProviderModel +from ursa.util.secrets import SecretReference + + +@pytest.fixture(autouse=True) +def clear_model_provider_caches(): + inference_providers._list_provider_models.cache_clear() + inference_providers.supported_model_providers.cache_clear() + yield + inference_providers._list_provider_models.cache_clear() + inference_providers.supported_model_providers.cache_clear() + + +class FakeOpenAI: + models = SimpleNamespace() + kwargs = None + + def __init__(self, **kwargs): + type(self).kwargs = kwargs + + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + +def test_list_provider_models_resolves_secret_and_preserves_metadata( + monkeypatch, +): + monkeypatch.setattr( + inference_providers, + "_client_type", + lambda module, name: FakeOpenAI, + ) + monkeypatch.setattr( + inference_providers, + "build_httpx_client", + lambda *, verify: f"client:{verify}", + ) + FakeOpenAI.models.list = lambda: SimpleNamespace( + data=[ + SimpleNamespace( + model_dump=lambda mode: { + "id": "chat-model", + "owned_by": "acme", + "created": 42, + "type": "chat", + } + ) + ] + ) + + models = inference_providers.list_provider_models( + InferenceProviderConfig( + base_url="https://models.example/v1", + api_key=SecretStr("secret"), + ssl_verify=False, + ) + ) + + assert models == [ + ProviderModel( + name="chat-model", + model_provider="openai", + type="chat", + metadata={"owned_by": "acme", "created": 42}, + ) + ] + assert FakeOpenAI.kwargs == { + "api_key": "secret", + "base_url": "https://models.example/v1", + "http_client": "client:False", + } + + +def test_list_provider_models_resolves_secret_reference(monkeypatch): + monkeypatch.setenv("MODELS_API_KEY", "from-env") + monkeypatch.setattr( + inference_providers, + "_client_type", + lambda module, name: FakeOpenAI, + ) + monkeypatch.setattr( + inference_providers, "build_httpx_client", lambda **_kwargs: object() + ) + FakeOpenAI.models.list = lambda: SimpleNamespace(data=[]) + + inference_providers.list_provider_models( + InferenceProviderConfig(api_key=SecretReference(env="MODELS_API_KEY")) + ) + + assert FakeOpenAI.kwargs["api_key"] == "from-env" + + +def test_list_provider_models_rejects_missing_secret(monkeypatch): + monkeypatch.delenv("MISSING_API_KEY", raising=False) + config = InferenceProviderConfig( + api_key=SecretReference(env="MISSING_API_KEY") + ) + + with pytest.raises(ValueError, match="API key is missing"): + inference_providers.list_provider_models(config) + + +def test_validate_model_provider_accepts_advertised_model(monkeypatch): + monkeypatch.setattr( + inference_providers, + "list_provider_models", + lambda _provider: [ProviderModel("gpt-test", "openai")], + ) + + assert ( + inference_providers.validate_model_provider( + ModelConfig( + model="gpt-test", + model_provider="openai", + api_key=SecretStr("secret"), + ), + "chat", + ) + is None + ) + + +def test_validate_model_provider_rejects_unadvertised_model(monkeypatch): + monkeypatch.setattr( + inference_providers, + "list_provider_models", + lambda _provider: [ProviderModel("another-model", "openai")], + ) + + with pytest.raises(ValueError, match="gpt-test.*not available"): + inference_providers.validate_model_provider( + ModelConfig( + model="gpt-test", + model_provider="openai", + api_key=SecretStr("secret"), + ), + "chat", + ) + + +def test_validate_model_provider_config_only_checks_connectivity(monkeypatch): + calls = [] + monkeypatch.setattr( + inference_providers, + "list_provider_models", + lambda provider: calls.append(provider) or [], + ) + provider = InferenceProviderConfig(api_key=SecretStr("secret")) + + assert inference_providers.validate_model_provider(provider, "chat") is None + assert calls == [provider] + + +@pytest.mark.parametrize("provider", inference_providers._MODEL_LISTERS) +def test_list_provider_models_dispatches_by_model_provider( + monkeypatch, provider +): + calls = [] + monkeypatch.setitem( + inference_providers._MODEL_LISTERS, + provider, + lambda config: calls.append(config) or [], + ) + config = ModelConfig(model="test", model_provider=provider) + + assert inference_providers.list_provider_models(config) == [] + assert calls == [config] + + +def test_list_provider_models_dispatches_litellm(monkeypatch): + calls = [] + monkeypatch.setitem( + inference_providers._MODEL_LISTERS, + "litellm", + lambda config: calls.append(config) or [], + ) + config = ModelConfig(model="test", model_provider="litellm") + + assert inference_providers.list_provider_models(config) == [] + assert calls == [config] + + +def test_list_provider_models_caches_equivalent_provider_requests(monkeypatch): + calls = [] + monkeypatch.setitem( + inference_providers._MODEL_LISTERS, + "openai", + lambda config: calls.append(config) or [{"id": "gpt-test"}], + ) + chat = ModelConfig( + model="gpt-test", + model_provider="openai", + base_url="https://models.example/v1", + ) + embedding = ModelConfig( + model="text-embedding-test", + model_provider="openai", + base_url="https://models.example/v1", + ) + + assert inference_providers.list_provider_models(chat) + assert inference_providers.list_provider_models(embedding) + assert calls == [chat] + + +def test_list_provider_models_uses_inference_provider_model_provider( + monkeypatch, +): + calls = [] + monkeypatch.setitem( + inference_providers._MODEL_LISTERS, + "anthropic", + lambda config: calls.append(config) or [], + ) + config = InferenceProviderConfig(model_provider="anthropic") + + assert inference_providers.list_provider_models(config) == [] + assert calls == [config] + + +def test_litellm_models_can_have_different_model_providers(monkeypatch): + monkeypatch.setitem( + inference_providers._MODEL_LISTERS, + "litellm", + lambda _config: [ + {"id": "openai/gpt-test", "litellm_provider": "openai"}, + { + "id": "anthropic/claude-test", + "litellm_provider": "anthropic", + }, + ], + ) + config = ModelConfig(model="test", model_provider="litellm") + + assert inference_providers.list_provider_models(config) == [ + ProviderModel( + "openai/gpt-test", + "openai", + metadata={"litellm_provider": "openai"}, + ), + ProviderModel( + "anthropic/claude-test", + "anthropic", + metadata={"litellm_provider": "anthropic"}, + ), + ] + + +def test_model_provider_is_inferred_from_model_name(monkeypatch): + monkeypatch.setitem( + inference_providers._MODEL_LISTERS, + "litellm", + lambda _config: [{"id": "claude-test", "owned_by": "gateway"}], + ) + config = ModelConfig(model="test", model_provider="litellm") + + assert inference_providers.list_provider_models(config) == [ + ProviderModel( + "claude-test", + "anthropic", + metadata={"owned_by": "gateway"}, + ) + ] + + +def test_ollama_listing_does_not_require_a_key(monkeypatch): + captured = {} + + class FakeOllama: + def __init__(self, **kwargs): + captured.update(kwargs) + + def list(self): + return SimpleNamespace(models=[{"model": "llama-test"}]) + + monkeypatch.setattr( + inference_providers, + "_client_type", + lambda module, name: FakeOllama, + ) + config = ModelConfig( + model="llama-test", + model_provider="ollama", + base_url="http://localhost:11434", + ssl_verify=False, + ) + + assert inference_providers.list_provider_models(config) == [ + ProviderModel("llama-test", "ollama") + ] + assert captured == {"host": "http://localhost:11434", "verify": False} + + +def test_validate_model_provider_accepts_google_qualified_model_name( + monkeypatch, +): + monkeypatch.setattr( + inference_providers, + "list_provider_models", + lambda _provider: [ProviderModel("models/gemini-test", "google_genai")], + ) + config = ModelConfig(model="gemini-test", model_provider="google_genai") + + assert inference_providers.validate_model_provider(config, "chat") is None + + +def test_supported_model_providers_are_installed_langchain_builtins( + monkeypatch, +): + from langchain.chat_models.base import _BUILTIN_PROVIDERS + + installed = {"langchain_openai", "langchain_anthropic"} + monkeypatch.setattr( + inference_providers, + "find_spec", + lambda module: object() if module in installed else None, + ) + inference_providers.supported_model_providers.cache_clear() + providers = inference_providers.supported_model_providers() + + assert providers == tuple( + provider + for provider, ( + module, + _class_name, + _creator, + ) in _BUILTIN_PROVIDERS.items() + if module.partition(".")[0] in installed + ) + inference_providers.supported_model_providers.cache_clear() + + +def test_supported_embedding_providers_use_embedding_registry(monkeypatch): + from langchain.embeddings.base import _BUILTIN_PROVIDERS + + installed = { + module.partition(".")[0] for module, _, _ in _BUILTIN_PROVIDERS.values() + } + monkeypatch.setattr( + inference_providers, + "find_spec", + lambda module: object() if module in installed else None, + ) + inference_providers.supported_model_providers.cache_clear() + + providers = inference_providers.supported_model_providers("embedding") + + assert providers == tuple(_BUILTIN_PROVIDERS) + inference_providers.supported_model_providers.cache_clear() + + +def test_sort_provider_models_prioritizes_models_for_picker_type(): + models = [ + ProviderModel("gpt-4-0613"), + ProviderModel("text-embedding-3-large"), + ProviderModel("gpt-4-realtime"), + ProviderModel("gpt-4"), + ProviderModel("embed-small"), + ProviderModel("whisper-1"), + ProviderModel("tts-1"), + ProviderModel("sora-2"), + ] + + assert [ + model.name + for model in inference_providers.sort_provider_models(models, "chat") + ] == [ + "gpt-4", + "gpt-4-0613", + "embed-small", + "text-embedding-3-large", + "tts-1", + "sora-2", + "whisper-1", + "gpt-4-realtime", + ] + assert [ + model.name + for model in inference_providers.sort_provider_models( + models, "embedding" + ) + ] == [ + "embed-small", + "text-embedding-3-large", + "gpt-4", + "gpt-4-0613", + "tts-1", + "sora-2", + "whisper-1", + "gpt-4-realtime", + ] + + +def test_sort_provider_models_prefers_recent_metadata_before_name_length(): + models = [ + ProviderModel("gpt-4", metadata={"created": 100}), + ProviderModel("gpt-5-long-snapshot", metadata={"created": 200}), + ProviderModel("gpt-5", metadata={"created_at": "2026-01-01T00:00:00Z"}), + ] + + assert [ + model.name + for model in inference_providers.sort_provider_models(models, "chat") + ] == ["gpt-5", "gpt-5-long-snapshot", "gpt-4"] diff --git a/tests/util/test_mcp.py b/tests/util/test_mcp.py index 5e794cc0..1139fb30 100644 --- a/tests/util/test_mcp.py +++ b/tests/util/test_mcp.py @@ -1,4 +1,11 @@ +import os +import subprocess +import sys +from pathlib import Path + import pytest +from langchain_core.messages import ToolMessage +from mcp import StdioServerParameters from mcp.client.session_group import ( SseServerParameters, StreamableHttpParameters, @@ -7,6 +14,8 @@ from ursa.util import mcp as mcp_mod from ursa.util.secrets import SecretTemplate +DUMMY_SERVER = Path(__file__).parents[1] / "tools" / "dummy_mcp_server.py" + def test_start_mcp_client_adds_httpx_factory_for_sse(monkeypatch): captured = {} @@ -15,7 +24,7 @@ class DummyClient: def __init__(self, connections): captured["connections"] = connections - monkeypatch.setattr(mcp_mod, "MultiServerMCPClient", DummyClient) + monkeypatch.setattr(mcp_mod, "UrsaMCPClient", DummyClient) mcp_mod.start_mcp_client({ "demo": SseServerParameters(url="https://example.com/sse") @@ -33,7 +42,7 @@ class DummyClient: def __init__(self, connections): captured["connections"] = connections - monkeypatch.setattr(mcp_mod, "MultiServerMCPClient", DummyClient) + monkeypatch.setattr(mcp_mod, "UrsaMCPClient", DummyClient) mcp_mod.start_mcp_client({ "demo": StreamableHttpParameters(url="https://example.com/mcp") @@ -51,7 +60,7 @@ class DummyClient: def __init__(self, connections): captured["connections"] = connections - monkeypatch.setattr(mcp_mod, "MultiServerMCPClient", DummyClient) + monkeypatch.setattr(mcp_mod, "UrsaMCPClient", DummyClient) monkeypatch.setattr( "keyring.get_password", lambda service, username: ( @@ -111,3 +120,83 @@ def test_mcp_header_rejects_invalid_secret_mapping(): headers={"Authorization": {"enb": "MCP_TOKEN"}}, ) }) + + +async def test_stdio_server_stderr_is_discarded(capsys): + client = mcp_mod.start_mcp_client({ + "demo": StdioServerParameters( + command=sys.executable, + args=[str(DUMMY_SERVER)], + ) + }) + tools, sources = await mcp_mod.load_mcp_tools_with_sources(client) + + assert tools + assert sources["add"] == "demo" + assert "dummy MCP diagnostic" not in capsys.readouterr().err + + +def test_stdio_proxy_can_redirect_stderr_to_file(tmp_path): + stderr_path = tmp_path / "demo-mcp.log" + subprocess.run( + [ + sys.executable, + "-m", + "ursa.util.mcp_stdio_proxy", + str(stderr_path), + sys.executable, + "-c", + "import sys; print('diagnostic', file=sys.stderr)", + ], + check=True, + ) + + assert stderr_path.read_text() == "diagnostic\n" + + +@pytest.mark.skipif( + sys.platform != "win32", + reason="Windows command shims are specific to Windows", +) +@pytest.mark.parametrize("extension", [".cmd", ".bat"]) +def test_stdio_proxy_launches_windows_command_shim(tmp_path, extension): + shim = tmp_path / f"npx{extension}" + shim.write_text("@echo off\r\necho shim diagnostic 1>&2\r\nexit /b 0\r\n") + stderr_path = tmp_path / "shim.log" + env = os.environ.copy() + env["PATH"] = str(tmp_path) + os.pathsep + env.get("PATH", "") + env["PATHEXT"] = ".COM;.EXE;.BAT;.CMD" + + subprocess.run( + [ + sys.executable, + "-m", + "ursa.util.mcp_stdio_proxy", + str(stderr_path), + "npx", + ], + check=True, + env=env, + ) + + assert stderr_path.read_text().strip() == "shim diagnostic" + + +async def test_upstream_adapter_puts_structured_content_in_tool_artifact(): + client = mcp_mod.start_mcp_client({ + "demo": StdioServerParameters( + command=sys.executable, + args=[str(DUMMY_SERVER)], + ) + }) + tools = await client.get_tools(server_name="demo") + add = next(tool for tool in tools if tool.name == "add") + result = await add.ainvoke({ + "type": "tool_call", + "name": "add", + "args": {"a": 2, "b": 3}, + "id": "add-call", + }) + + assert isinstance(result, ToolMessage) + assert result.artifact == {"structured_content": {"result": 5}} diff --git a/uv.lock b/uv.lock index b5dc5d0e..e014357a 100644 --- a/uv.lock +++ b/uv.lock @@ -2969,16 +2969,16 @@ wheels = [ [[package]] name = "langchain-mcp-adapters" -version = "0.1.12" +version = "0.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain-core" }, { name = "mcp" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/08/c7/2f6fdebb39d20e97866f8dce67b2f910e814cb618cded9e29e5c9674bd7c/langchain_mcp_adapters-0.1.12.tar.gz", hash = "sha256:0c7baa974278e44148b36fe6cb04173e9bf3c540619017a8f1bb602f90a24c1f", size = 29353, upload-time = "2025-10-30T21:19:39.893Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0e/66/1cc7039e2daaddcdea9d8887851fe6eb67401925999b2aa394aa855c7132/langchain_mcp_adapters-0.2.2.tar.gz", hash = "sha256:12d39e91ae4389c54b61b221094e53850b6e152934d8bc10c80665d600e76530", size = 37942, upload-time = "2026-03-16T17:13:30.35Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/15/5e/285001f98420304b2a03d38b341a9c0ec9ebb2a47d06fc0338d5f757d145/langchain_mcp_adapters-0.1.12-py3-none-any.whl", hash = "sha256:ea9894ed8baf83dce485cf4fb64d7afb74c34b95e2e627fde6bf307eaa49ea1c", size = 20429, upload-time = "2025-10-30T21:19:38.85Z" }, + { url = "https://files.pythonhosted.org/packages/7d/2f/15d5e6c1765d8404a9cce38d8c81d7b33fb3392f9db5b992c000dddbd2a3/langchain_mcp_adapters-0.2.2-py3-none-any.whl", hash = "sha256:d08e64954e86281002653071b7430e0377c9a577cb4ac3143abfeb3e24ef8797", size = 23288, upload-time = "2026-03-16T17:13:29.073Z" }, ] [[package]] @@ -3138,6 +3138,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b5/40/23569737873cc9637fd488606347e9dd92b9fa37ba4fcda1f98ee5219a97/latexcodec-3.0.1-py3-none-any.whl", hash = "sha256:a9eb8200bff693f0437a69581f7579eb6bca25c4193515c09900ce76451e452e", size = 18532, upload-time = "2025-06-17T18:47:30.726Z" }, ] +[[package]] +name = "linkify-it-py" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "uc-micro-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" }, +] + [[package]] name = "lxml" version = "6.0.2" @@ -3310,6 +3322,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -3497,6 +3514,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" }, ] +[[package]] +name = "mdit-py-plugins" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -6344,15 +6373,15 @@ wheels = [ [[package]] name = "rich" -version = "13.9.4" +version = "14.3.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149, upload-time = "2024-11-01T16:43:57.873Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/67/cae617f1351490c25a4b8ac3b8b63a4dda609295d8222bad12242dfdc629/rich-14.3.4.tar.gz", hash = "sha256:817e02727f2b25b40ef56f5aa2217f400c8489f79ca8f46ea2b70dd5e14558a9", size = 230524, upload-time = "2026-04-11T02:57:45.419Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424, upload-time = "2024-11-01T16:43:55.817Z" }, + { url = "https://files.pythonhosted.org/packages/b3/76/6d163cfac87b632216f71879e6b2cf17163f773ff59c00b5ff4900a80fa3/rich-14.3.4-py3-none-any.whl", hash = "sha256:07e7adb4690f68864777b1450859253bed81a99a31ac321ac1817b2313558952", size = 310480, upload-time = "2026-04-11T02:57:47.484Z" }, ] [[package]] @@ -7186,6 +7215,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl", hash = "sha256:a4468e1b37bb318f8a86514f65814e1afc977cf29b3992a4500d9dd305dcceb0", size = 14154, upload-time = "2024-03-12T14:34:36.569Z" }, ] +[[package]] +name = "textual" +version = "8.2.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", extra = ["linkify"] }, + { name = "mdit-py-plugins" }, + { name = "platformdirs" }, + { name = "pygments" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/21/39a76b01bd5eea82a04baaca7580e105d8c59450df03998345bb2cfb307b/textual-8.2.8.tar.gz", hash = "sha256:3f106a9fbc73e39dd266c9712432087de78a6d644084c7c241d6a25c3169115b", size = 1860502, upload-time = "2026-06-30T06:51:24.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl", hash = "sha256:267375fd402dc8d981457212efa71f0e3365fd17bba144ba9bb3ed7563cb374a", size = 731418, upload-time = "2026-06-30T06:51:26.364Z" }, +] + +[package.optional-dependencies] +syntax = [ + { name = "tree-sitter" }, + { name = "tree-sitter-bash" }, + { name = "tree-sitter-css" }, + { name = "tree-sitter-go" }, + { name = "tree-sitter-html" }, + { name = "tree-sitter-java" }, + { name = "tree-sitter-javascript" }, + { name = "tree-sitter-json" }, + { name = "tree-sitter-markdown" }, + { name = "tree-sitter-python" }, + { name = "tree-sitter-regex" }, + { name = "tree-sitter-rust" }, + { name = "tree-sitter-sql" }, + { name = "tree-sitter-toml" }, + { name = "tree-sitter-xml" }, + { name = "tree-sitter-yaml" }, +] + [[package]] name = "threadpoolctl" version = "3.6.0" @@ -7418,6 +7484,281 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" }, ] +[[package]] +name = "tree-sitter" +version = "0.26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/03/5600b84aff2e6c4fe80cfebb4063fe2f50299521befe5f6092ab8c082f4a/tree_sitter-0.26.0.tar.gz", hash = "sha256:b40c219edccc4564530c96f8f1556f6202b37cda964d1cbd7bd2b7e68b40a245", size = 191423, upload-time = "2026-06-30T12:14:27.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/18/78aae7e4b5a36daaebb0276e4b07d084d45298758000787838e89329e11f/tree_sitter-0.26.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1d6fe0e8fb4df77b5ee816228e2c4475a63d8cc1d4d3a7ffd7097b2b87fc3e95", size = 148679, upload-time = "2026-06-30T12:13:52.27Z" }, + { url = "https://files.pythonhosted.org/packages/24/e4/b371b9553b0e47d130fc2073e56cab94fecc868be04666bf5bbd1fcd1cc9/tree_sitter-0.26.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:514a9bf8993e5210e7970736aaf6020d1759b670e195ef17b1c48f586aa30736", size = 140759, upload-time = "2026-06-30T12:13:53.221Z" }, + { url = "https://files.pythonhosted.org/packages/22/7d/266fb0f2c41e6fb00b0f40e7a3338cdf99651e6a6511ca72bc78fc697636/tree_sitter-0.26.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10f0d4eb94aa7242dcb7f554bcd24dd7ba1c114f00d58759ba08c7a46c8ec51a", size = 637206, upload-time = "2026-06-30T12:13:54.334Z" }, + { url = "https://files.pythonhosted.org/packages/40/9f/47cf22febb47132d5b3a507a27bb99ef89fe5c8ec420a13c6daa9b64f782/tree_sitter-0.26.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:335294ce0504fcefde5245dff596778ffaf820205b98ae0b549c72e48855f1d8", size = 664758, upload-time = "2026-06-30T12:13:55.42Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4d/8d144ca3beb46a62a5102b6deac76bb0da55235c2c7840faf3b12f2e9d97/tree_sitter-0.26.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9997ba61368c48ed54e715676afadf703947a1542464e39d047764fb3624b01", size = 647438, upload-time = "2026-06-30T12:13:56.523Z" }, + { url = "https://files.pythonhosted.org/packages/4d/ed/ed1d6e78520c4fb64ed52fec3f2947bf8c1fbad7bc24e282c56193c9ba42/tree_sitter-0.26.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c56581ad256c4195a21bfe449fed5d44a02fe83a4a7d6e70e6ec302c881191c7", size = 661944, upload-time = "2026-06-30T12:13:57.82Z" }, + { url = "https://files.pythonhosted.org/packages/10/83/45f5bd43db1b8248d2fd08ef6cbe43e2725c539e09a2cfb8bc2818646788/tree_sitter-0.26.0-cp311-cp311-win_amd64.whl", hash = "sha256:0f8793fd18ad7eec276ed4b51c097b4bf2002b357259b66b0d75db1f3f41c754", size = 129496, upload-time = "2026-06-30T12:13:59.216Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8d/be68e6c04563eb54145424cc83fe0aa8b0ba6c90d8989cf8a032671b5f16/tree_sitter-0.26.0-cp311-cp311-win_arm64.whl", hash = "sha256:dea4b4e27d49e9ec5b785d4f994da000e6726882fcc6ad05ec98478500c71aef", size = 116484, upload-time = "2026-06-30T12:14:00.147Z" }, + { url = "https://files.pythonhosted.org/packages/87/ca/565702c44815393e3a973552ad546db4e5ca081ca8698640b4e93d809f51/tree_sitter-0.26.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6cb2bd20efb2544c19ac54486ab7cb8ec7b36f913bbe1ce95df84acb96743d9c", size = 148934, upload-time = "2026-06-30T12:14:01.188Z" }, + { url = "https://files.pythonhosted.org/packages/54/6f/8bb61957f16ec1b1d92410a006cdc84a952b6352a7313b2ad299f2d21484/tree_sitter-0.26.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:918d89529786873f0982a0f59c2a303cd065fbfd1b903d71a8e4e1584f67b42e", size = 140820, upload-time = "2026-06-30T12:14:02.087Z" }, + { url = "https://files.pythonhosted.org/packages/78/0a/8a6f08559182643a814a4ab559948ae817b2851890fd9b995a4fff6541ce/tree_sitter-0.26.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:30a88be89ff1f2755297f81e8080d88b795dd98720c3f9fa2acf93873182cc95", size = 638844, upload-time = "2026-06-30T12:14:03.428Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2f/6e6781b31677231366cb3cf27bc8269157f6d4b03c9032865a4f5f2bbe7e/tree_sitter-0.26.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a6b333b0282d8bb0af741f9b018bd2523d4eecb2686bf6717066a625fecfaa4", size = 667487, upload-time = "2026-06-30T12:14:04.669Z" }, + { url = "https://files.pythonhosted.org/packages/02/0b/0483078c8567445557a7015b0e5b187f6d7d4fda73464df9c4bdea7f7f3c/tree_sitter-0.26.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3f3c44339dd34fe8eb2b8d5aa7610660499a795f70376b130bbee7a437337280", size = 647975, upload-time = "2026-06-30T12:14:05.797Z" }, + { url = "https://files.pythonhosted.org/packages/27/68/da83ca72c984e96ab4eb3bee0db1a6ffb5de1c8c455f92bd9f420cde7f0e/tree_sitter-0.26.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:94550e13b6ae576969da40246f4c4abb206380b5375ad43f26dd9151d55438e3", size = 665018, upload-time = "2026-06-30T12:14:07.278Z" }, + { url = "https://files.pythonhosted.org/packages/d1/36/4d67927fd47b89af4a00f65f55a7370e28778cd50e972c2430487e3ecc27/tree_sitter-0.26.0-cp312-cp312-win_amd64.whl", hash = "sha256:ca89e361a276dbc934b28a43dd881199e25d34ff5493ee0ce45f3c52a6124a37", size = 129619, upload-time = "2026-06-30T12:14:08.373Z" }, + { url = "https://files.pythonhosted.org/packages/ed/72/cdefad523eb78710679c6da6a79e3d90f5afd32b1c6aa5a17bac7eef99f6/tree_sitter-0.26.0-cp312-cp312-win_arm64.whl", hash = "sha256:bc6cb01d5ee75c85424aa1f1c72a82d8f07fd52539a0f3c4a6ed3e8721079b84", size = 116545, upload-time = "2026-06-30T12:14:09.273Z" }, + { url = "https://files.pythonhosted.org/packages/cb/b0/465257cf8f972ad9f9812ec1cbaa8ec210ebebb601ade9a15881aa2436b4/tree_sitter-0.26.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ed0889dbed843ce45ede9f5169c0b2dea2222f12685844a03fadb81f12705867", size = 148893, upload-time = "2026-06-30T12:14:10.541Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ec/19d093e854b45e807fecfdd26105c266f43aeecc39c4dc97992a7074ad5a/tree_sitter-0.26.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6189c6c340c7384357711e3d92645e96bfb79f7a502f86de1ebdb23eb43f7dab", size = 140829, upload-time = "2026-06-30T12:14:11.626Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ee/87e74671ed63a837e7a1f17ab94aa3913871e033b27523d8e7b83d6f7ad0/tree_sitter-0.26.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ff2e0750b7daa722302838356d7b65e303829b7eb73c915df127ddba115e1d1", size = 639334, upload-time = "2026-06-30T12:14:12.836Z" }, + { url = "https://files.pythonhosted.org/packages/66/e7/f7e04cd9dff6b6ac0adf23922796fbc76accd4cf4bcda50542748d485679/tree_sitter-0.26.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7075ef857ef86f327dbb72d1e2574dda78db5754b3a1fca6506acd7fe5d561a7", size = 668102, upload-time = "2026-06-30T12:14:14.035Z" }, + { url = "https://files.pythonhosted.org/packages/d3/90/0bfb16b7894fea728c774a89d5af421a9368a2f913bbd4e8dcab7caaecfb/tree_sitter-0.26.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:26c996c1edfee86e977bb3f5462e74fcec0d0b0db1e85a3c475875763caa03be", size = 648560, upload-time = "2026-06-30T12:14:15.302Z" }, + { url = "https://files.pythonhosted.org/packages/cd/e6/0fe05ba396e9623b0ae40ccf34171336b8701ec8d7bd0ee9f5224d638665/tree_sitter-0.26.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:00289bfe7978f3e0dc0ce69813a20fa9f44ea4c100b3ec62043e5eb74ccfc3a2", size = 665121, upload-time = "2026-06-30T12:14:16.403Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/a944b1ca35bed6068dc84a9967aaf3049d8cc0b7a36179eea8787270a6ab/tree_sitter-0.26.0-cp313-cp313-win_amd64.whl", hash = "sha256:93e220cab7e6a823efeb2046c49171427de92ef71c7c681c01820d14d8d3721f", size = 129615, upload-time = "2026-06-30T12:14:17.463Z" }, + { url = "https://files.pythonhosted.org/packages/09/ef/c7ca48293580d2249f36940c4eed5b4ddeb9ce75baf9a4ef30621987e0c7/tree_sitter-0.26.0-cp313-cp313-win_arm64.whl", hash = "sha256:b31a8195d2f224224c530ac814632d98c1dcc123d227442c07c736e86b70d564", size = 116525, upload-time = "2026-06-30T12:14:18.53Z" }, + { url = "https://files.pythonhosted.org/packages/c5/7a/4d84e6f6ae2c3e757490dd84de251712c31e293dfe31f28da1ec019cefa2/tree_sitter-0.26.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5a3c93a352b7e6f70f73e121bbfa2d0117ba7478bd51114ed35c91b0b78814fa", size = 148901, upload-time = "2026-06-30T12:14:19.452Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d9/efe62ec65dc9d096e834d27b8c058127e2146e42ff3380b822a233f016a6/tree_sitter-0.26.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5fc2f41bf246ff2f70a9cc3690be35ec7580a4923151873d898c8bcb1a4503d3", size = 140805, upload-time = "2026-06-30T12:14:20.478Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2c/c82326b7b97e3c485c18679883b16f89e5e913c639d3b219d3da70c9e67e/tree_sitter-0.26.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8ea92a255c91671a7ec4625aba3ab7bb5220c423630ffbf83c45d7312abe084", size = 640586, upload-time = "2026-06-30T12:14:21.527Z" }, + { url = "https://files.pythonhosted.org/packages/e2/7a/f56e7d8282859452611024c7cbc623bfba5b24b8cb9b8f8bc88c5219fe9a/tree_sitter-0.26.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f665510f0fcf4636fb9696f1f7853bed7a3bd764b7bb0cb8494e619c14ed5a0c", size = 668300, upload-time = "2026-06-30T12:14:22.728Z" }, + { url = "https://files.pythonhosted.org/packages/91/51/240ee81b9d5e9ca0a6cb1528e8605ffa70ab58c89ce126631be96d3e4bae/tree_sitter-0.26.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:253df7ab82cc0a9d311cd65f06e9f99fb3eac55996ae9fc94da22f123a861b90", size = 649627, upload-time = "2026-06-30T12:14:23.819Z" }, + { url = "https://files.pythonhosted.org/packages/6a/54/760035cefedf9eb44f0f84c4ac22f1322e73155853e272576ee876336312/tree_sitter-0.26.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ff80d4833d330a73184a3ac5132abe93c575d2dea31975c6f15c0d21fef238aa", size = 664885, upload-time = "2026-06-30T12:14:25.064Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1b/0b36fe2a984ecedc4ce6aefd5d56447a6626a8e9b595c4e48658510ce8f8/tree_sitter-0.26.0-cp314-cp314-win_amd64.whl", hash = "sha256:a4033fecc8f606c7f2e8b8014d0057b74668a7f0152763606f7bc25c5f9ec64c", size = 132688, upload-time = "2026-06-30T12:14:26.106Z" }, + { url = "https://files.pythonhosted.org/packages/4d/74/ebc041a13fbf40144afdb0d4b447e48e0b4012ca866c63de8b48f801f0c1/tree_sitter-0.26.0-cp314-cp314-win_arm64.whl", hash = "sha256:823251c4b6725a7c03ed497a339135ede7ae4bdde75bb8be7ef5e305aeb4ff52", size = 120287, upload-time = "2026-06-30T12:14:26.991Z" }, +] + +[[package]] +name = "tree-sitter-bash" +version = "0.25.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/0e/f0108be910f1eef6499eabce517e79fe3b12057280ed398da67ce2426cba/tree_sitter_bash-0.25.1.tar.gz", hash = "sha256:bfc0bdaa77bc1e86e3c6652e5a6e140c40c0a16b84185c2b63ad7cd809b88f14", size = 419703, upload-time = "2025-12-02T17:01:08.849Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/8e/37e7364d9c9c58da89e05c510671d8c45818afd7b31c6939ab72f8dc6c04/tree_sitter_bash-0.25.1-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:0e6235f59e366d220dde7d830196bed597d01e853e44d8ccd1a82c5dd2500acf", size = 194160, upload-time = "2025-12-02T17:00:59.047Z" }, + { url = "https://files.pythonhosted.org/packages/23/bb/2d2cfbb1f89aaeb1ec892624f069d92d058d06bb66f16b9ec9fb5873ab60/tree_sitter_bash-0.25.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:f4a34a6504c7c5b2a9b8c5c4065531dea19ca2c35026e706cf2eeeebe2c92512", size = 202659, upload-time = "2025-12-02T17:01:00.275Z" }, + { url = "https://files.pythonhosted.org/packages/25/f0/1bb25519be27460255d3899db677313cfa1e6306988fbf456a3d7e211bbb/tree_sitter_bash-0.25.1-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e76c4cfb20b076552406782b7f8c2a3946835993df0a44df006de54b7030c7dc", size = 230596, upload-time = "2025-12-02T17:01:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/d7/22/9f70bc3d3b942ab9fc0f89c1dc9e087519a3a94f64ae6b7377aae3a7a0f0/tree_sitter_bash-0.25.1-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3f484c4bb8796cde7a87ca351e6116f09653edac0eb3c6d238566359dd28b117", size = 231981, upload-time = "2025-12-02T17:01:02.859Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c3/f1540e42cd41b323c6821e45e52e1aed6ed386209aad52db996f05703963/tree_sitter_bash-0.25.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5e76af6df46d958c7f5b6d5884c9743218e3902a00ccb493ec92728b1084430b", size = 228364, upload-time = "2025-12-02T17:01:03.997Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a0/c3050a6277dfcac8c480f514dc4fe49f3f65f0eac68b4702cbaca2584e85/tree_sitter_bash-0.25.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a3332d71c7b7d5f78259b19d02d0ea111fcb82b72712ee4a93aaa5b226d3f0a8", size = 230074, upload-time = "2025-12-02T17:01:05.05Z" }, + { url = "https://files.pythonhosted.org/packages/71/0f/203fe6b27211387f4b9ba8c4a321567ca4ded2624dae6ccdbd2b6e940e17/tree_sitter_bash-0.25.1-cp310-abi3-win_amd64.whl", hash = "sha256:52a6802d9218f86278aa3e8b459c3abdad67eed0fde1f9f13aca5b6c634217a6", size = 195574, upload-time = "2025-12-02T17:01:06.412Z" }, + { url = "https://files.pythonhosted.org/packages/47/75/4ca1a9fabd8fb5aea78cea70f7837ce4dbf2afae115f62051e5fa99cba1c/tree_sitter_bash-0.25.1-cp310-abi3-win_arm64.whl", hash = "sha256:59115057ec2bae319e8082ff29559861045002964c3431ccb0fc92aa4bc9bccb", size = 191196, upload-time = "2025-12-02T17:01:07.486Z" }, +] + +[[package]] +name = "tree-sitter-css" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/37/7d60171240d4c5ba330f05b725dfb5e5fd5b7cbe0aa98ef9e77f77f868f5/tree_sitter_css-0.25.0.tar.gz", hash = "sha256:2fc996bf05b04e06061e88ee4c60837783dc4e62a695205acbc262ee30454138", size = 43232, upload-time = "2025-09-28T11:37:13.387Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/a9/69e556f15ca774638bd79005369213dfbd41995bf032ce81cf3ffe086b8a/tree_sitter_css-0.25.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ddce6f84eeb0bb2877b4587b07bffb0753040c44d811ed9ab2af978c313beda8", size = 29933, upload-time = "2025-09-28T11:37:07.703Z" }, + { url = "https://files.pythonhosted.org/packages/4d/28/ebcbcbba812d3e407f2f393747330eb8843e0c69d159024e33460b622aab/tree_sitter_css-0.25.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:5a2a9c875037ef5f9da57697fb8075086476d42a49d25a88dcca60dfc09bd092", size = 31097, upload-time = "2025-09-28T11:37:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/86/a2/6f9658c723f3a857367c198bd4f50d854aa9468783b418407492c9634a44/tree_sitter_css-0.25.0-cp310-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4f5e1135bfd01bce24e2fc7bca1381f52bdd6c6282ee28f7aa77185340bcd135", size = 41713, upload-time = "2025-09-28T11:37:09.101Z" }, + { url = "https://files.pythonhosted.org/packages/85/bb/f74eea6839cb1ff6b5851c6ed33b18e65309eb347bbbe027c93e70e6c691/tree_sitter_css-0.25.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b6d0084536828c733a66524a43c9df89f335971d5b1b973e9d1c42ba9dd426b", size = 42312, upload-time = "2025-09-28T11:37:09.757Z" }, + { url = "https://files.pythonhosted.org/packages/ca/fd/031ef1a5938441c98342faf70bb30998683b2130d4b55c282d76b2083f4a/tree_sitter_css-0.25.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8a83825daf538656cb88f4f7a0dd9963e3f204e83e7f8d92131f17e5bd712a77", size = 41585, upload-time = "2025-09-28T11:37:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/96/74/9f269bb3644a0511c1c263135e32d38a7f2af39cbba24d59a1633a5ebbc1/tree_sitter_css-0.25.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b486c097d250a598fba5f1f46f62697c7f4428252c8bdaad696a907ee913421d", size = 41490, upload-time = "2025-09-28T11:37:11.134Z" }, + { url = "https://files.pythonhosted.org/packages/04/9f/d4f1d3164b692b97266274dad6437586e0614f75080b7795fc7bfa5bf8ff/tree_sitter_css-0.25.0-cp310-abi3-win_amd64.whl", hash = "sha256:fe319e4ad1b8327afbd9758b3ae22b09226d6c28dc9b022bcadabdaf6ea3716c", size = 32416, upload-time = "2025-09-28T11:37:11.808Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/fa62d70cb324788bcced741b5e19864ccf4c51ca31766a9f56a6b46a5cf6/tree_sitter_css-0.25.0-cp310-abi3-win_arm64.whl", hash = "sha256:4fc2c82645cd593f1c695b4d6b678d71e633212ca030f26dedee4f92434bfe21", size = 31057, upload-time = "2025-09-28T11:37:12.734Z" }, +] + +[[package]] +name = "tree-sitter-go" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/05/727308adbbc79bcb1c92fc0ea10556a735f9d0f0a5435a18f59d40f7fd77/tree_sitter_go-0.25.0.tar.gz", hash = "sha256:a7466e9b8d94dda94cae8d91629f26edb2d26166fd454d4831c3bf6dfa2e8d68", size = 93890, upload-time = "2025-08-29T06:20:25.044Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/aa/0984707acc2b9bb461fe4a41e7e0fc5b2b1e245c32820f0c83b3c602957c/tree_sitter_go-0.25.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b852993063a3429a443e7bd0aa376dd7dd329d595819fabf56ac4cf9d7257b54", size = 47117, upload-time = "2025-08-29T06:20:14.286Z" }, + { url = "https://files.pythonhosted.org/packages/32/16/dd4cb124b35e99239ab3624225da07d4cb8da4d8564ed81d03fcb3a6ba9f/tree_sitter_go-0.25.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:503b81a2b4c31e302869a1de3a352ad0912ccab3df9ac9950197b0a9ceeabd8f", size = 48674, upload-time = "2025-08-29T06:20:17.557Z" }, + { url = "https://files.pythonhosted.org/packages/86/fb/b30d63a08044115d8b8bd196c6c2ab4325fb8db5757249a4ef0563966e2e/tree_sitter_go-0.25.0-cp310-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04b3b3cb4aff18e74e28d49b716c6f24cb71ddfdd66768987e26e4d0fa812f74", size = 66418, upload-time = "2025-08-29T06:20:18.345Z" }, + { url = "https://files.pythonhosted.org/packages/26/21/d3d88a30ad007419b2c97b3baeeef7431407faf9f686195b6f1cad0aedf9/tree_sitter_go-0.25.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:148255aca2f54b90d48c48a9dbb4c7faad6cad310a980b2c5a5a9822057ed145", size = 72006, upload-time = "2025-08-29T06:20:19.14Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d0/0dd6442353ced8a88bbda9e546f4ea29e381b59b5a40b122e5abb586bb6c/tree_sitter_go-0.25.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4d338116cdf8a6c6ff990d2441929b41323ef17c710407abe0993c13417d6aad", size = 70603, upload-time = "2025-08-29T06:20:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/01/e2/ee5e09f63504fc286539535d374d2eaa0e7d489b80f8f744bb3962aff22a/tree_sitter_go-0.25.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5608e089d2a29fa8d2b327abeb2ad1cdb8e223c440a6b0ceab0d3fa80bdeebae", size = 66088, upload-time = "2025-08-29T06:20:22.336Z" }, + { url = "https://files.pythonhosted.org/packages/6e/b6/d9142583374720e79aca9ccb394b3795149a54c012e1dfd80738df2d984e/tree_sitter_go-0.25.0-cp310-abi3-win_amd64.whl", hash = "sha256:30d4ada57a223dfc2c32d942f44d284d40f3d1215ddcf108f96807fd36d53022", size = 48152, upload-time = "2025-08-29T06:20:23.089Z" }, + { url = "https://files.pythonhosted.org/packages/9e/00/9a2638e7339236f5b01622952a4d71c1474dd3783d1982a89555fc1f03b1/tree_sitter_go-0.25.0-cp310-abi3-win_arm64.whl", hash = "sha256:d5d62362059bf79997340773d47cc7e7e002883b527a05cca829c46e40b70ded", size = 46752, upload-time = "2025-08-29T06:20:24.235Z" }, +] + +[[package]] +name = "tree-sitter-html" +version = "0.23.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/06/ad1c53c79da15bef85939aa022d72301e12a9773e9bb9a5e6a6f65b7753a/tree_sitter_html-0.23.2.tar.gz", hash = "sha256:bc9922defe23144d9146bc1509fcd00d361bf6b3303f9effee6532c6a0296961", size = 13977, upload-time = "2024-11-11T05:58:07.403Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/27/b846852b567601c4df765bcb4636085a3260e9f03ae21e0ef2e7c7f957fc/tree_sitter_html-0.23.2-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9e1641d5edf5568a246c6c47b947ed524b5bf944664e6473b21d4ae568e28ee9", size = 14787, upload-time = "2024-11-11T05:57:58.684Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/827c315deb156bb8cac541da800c4bd62878f50a28b7498fbb722bddd225/tree_sitter_html-0.23.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:3d0a83dd6cd1c7d4bcf6287b5145c92140f0194f8516f329ae8b9e952fbfa8ff", size = 15232, upload-time = "2024-11-11T05:58:00.139Z" }, + { url = "https://files.pythonhosted.org/packages/91/cb/2028fe446d0e18edf3737d91edcb6430f2c97f2296b8cd760702dfa13d90/tree_sitter_html-0.23.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81b3775732fffc0abd275a419ef018fd4c1ad4044b2a2e422f3378d93c30eded", size = 39109, upload-time = "2024-11-11T05:58:00.986Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/b24f5e66be51447cf7e9bcce3d9440a6b4f17021da85779a51566646a7c7/tree_sitter_html-0.23.2-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4bdaa7ac5030d416aea0c512d4810ef847bbbd62d61e3d213f370b64ce147293", size = 39630, upload-time = "2024-11-11T05:58:02.424Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d5/31b46cb362ad9679af21ff8b75d846fb7522ecf949beea4fddc86e97815d/tree_sitter_html-0.23.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d2e9631b66041a4fd792d7f79a0c4128adb3bfc71f3dcb7e1a3eab5dbee77d67", size = 37440, upload-time = "2024-11-11T05:58:03.819Z" }, + { url = "https://files.pythonhosted.org/packages/28/30/03910b7c037105f33166439f0518dd0aa4f1b7ef8c9d7367c6e9cc6b5681/tree_sitter_html-0.23.2-cp39-abi3-win_amd64.whl", hash = "sha256:85095f49f9e57f0ac9087a3e830783352c8447fdda55b1c1139aa47e5eaa0e21", size = 17765, upload-time = "2024-11-11T05:58:05.163Z" }, + { url = "https://files.pythonhosted.org/packages/20/32/63761055b03c69202a0e67b6e9a5cb3578da23aeefb62ee3e7ec2c1b0ff2/tree_sitter_html-0.23.2-cp39-abi3-win_arm64.whl", hash = "sha256:0f65ed9e877144d0f04ade5644e5b0e88bf98a9e60bce65235c99905623e2f1a", size = 15576, upload-time = "2024-11-11T05:58:06.577Z" }, +] + +[[package]] +name = "tree-sitter-java" +version = "0.23.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/dc/eb9c8f96304e5d8ae1663126d89967a622a80937ad2909903569ccb7ec8f/tree_sitter_java-0.23.5.tar.gz", hash = "sha256:f5cd57b8f1270a7f0438878750d02ccc79421d45cca65ff284f1527e9ef02e38", size = 138121, upload-time = "2024-12-21T18:24:26.936Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/21/b3399780b440e1567a11d384d0ebb1aea9b642d0d98becf30fa55c0e3a3b/tree_sitter_java-0.23.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:355ce0308672d6f7013ec913dee4a0613666f4cda9044a7824240d17f38209df", size = 58926, upload-time = "2024-12-21T18:24:12.53Z" }, + { url = "https://files.pythonhosted.org/packages/57/ef/6406b444e2a93bc72a04e802f4107e9ecf04b8de4a5528830726d210599c/tree_sitter_java-0.23.5-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:24acd59c4720dedad80d548fe4237e43ef2b7a4e94c8549b0ca6e4c4d7bf6e69", size = 62288, upload-time = "2024-12-21T18:24:14.634Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6c/74b1c150d4f69c291ab0b78d5dd1b59712559bbe7e7daf6d8466d483463f/tree_sitter_java-0.23.5-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9401e7271f0b333df39fc8a8336a0caf1b891d9a2b89ddee99fae66b794fc5b7", size = 85533, upload-time = "2024-12-21T18:24:16.695Z" }, + { url = "https://files.pythonhosted.org/packages/29/09/e0d08f5c212062fd046db35c1015a2621c2631bc8b4aae5740d7adb276ad/tree_sitter_java-0.23.5-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:370b204b9500b847f6d0c5ad584045831cee69e9a3e4d878535d39e4a7e4c4f1", size = 84033, upload-time = "2024-12-21T18:24:18.758Z" }, + { url = "https://files.pythonhosted.org/packages/43/56/7d06b23ddd09bde816a131aa504ee11a1bbe87c6b62ab9b2ed23849a3382/tree_sitter_java-0.23.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:aae84449e330363b55b14a2af0585e4e0dae75eb64ea509b7e5b0e1de536846a", size = 82564, upload-time = "2024-12-21T18:24:20.493Z" }, + { url = "https://files.pythonhosted.org/packages/da/d6/0528c7e1e88a18221dbd8ccee3825bf274b1fa300f745fd74eb343878043/tree_sitter_java-0.23.5-cp39-abi3-win_amd64.whl", hash = "sha256:1ee45e790f8d31d416bc84a09dac2e2c6bc343e89b8a2e1d550513498eedfde7", size = 60650, upload-time = "2024-12-21T18:24:22.902Z" }, + { url = "https://files.pythonhosted.org/packages/72/57/5bab54d23179350356515526fff3cc0f3ac23bfbc1a1d518a15978d4880e/tree_sitter_java-0.23.5-cp39-abi3-win_arm64.whl", hash = "sha256:402efe136104c5603b429dc26c7e75ae14faaca54cfd319ecc41c8f2534750f4", size = 59059, upload-time = "2024-12-21T18:24:24.934Z" }, +] + +[[package]] +name = "tree-sitter-javascript" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/59/e0/e63103c72a9d3dfd89a31e02e660263ad84b7438e5f44ee82e443e65bbde/tree_sitter_javascript-0.25.0.tar.gz", hash = "sha256:329b5414874f0588a98f1c291f1b28138286617aa907746ffe55adfdcf963f38", size = 132338, upload-time = "2025-09-01T07:13:44.792Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/df/5106ac250cd03661ebc3cc75da6b3d9f6800a3606393a0122eca58038104/tree_sitter_javascript-0.25.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b70f887fb269d6e58c349d683f59fa647140c410cfe2bee44a883b20ec92e3dc", size = 64052, upload-time = "2025-09-01T07:13:36.865Z" }, + { url = "https://files.pythonhosted.org/packages/b1/8f/6b4b2bc90d8ab3955856ce852cc9d1e82c81d7ab9646385f0e75ffd5b5d3/tree_sitter_javascript-0.25.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:8264a996b8845cfce06965152a013b5d9cbb7d199bc3503e12b5682e62bb1de1", size = 66440, upload-time = "2025-09-01T07:13:37.962Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c4/7da74ecdcd8a398f88bd003a87c65403b5fe0e958cdd43fbd5fd4a398fcf/tree_sitter_javascript-0.25.0-cp310-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9dc04ba91fc8583344e57c1f1ed5b2c97ecaaf47480011b92fbeab8dda96db75", size = 99728, upload-time = "2025-09-01T07:13:38.755Z" }, + { url = "https://files.pythonhosted.org/packages/96/c8/97da3af4796495e46421e9344738addb3602fa6426ea695be3fcbadbee37/tree_sitter_javascript-0.25.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:199d09985190852e0912da2b8d26c932159be314bc04952cf917ed0e4c633e6b", size = 106072, upload-time = "2025-09-01T07:13:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/13/be/c964e8130be08cc9bd6627d845f0e4460945b158429d39510953bbcb8fcc/tree_sitter_javascript-0.25.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dfcf789064c58dc13c0a4edb550acacfc6f0f280577f1e7a00de3e89fc7f8ddc", size = 104388, upload-time = "2025-09-01T07:13:40.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/89/9b773dee0f8961d1bb8d7baf0a204ab587618df19897c1ef260916f318ec/tree_sitter_javascript-0.25.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1b852d3aee8a36186dbcc32c798b11b4869f9b5041743b63b65c2ef793db7a54", size = 98377, upload-time = "2025-09-01T07:13:41.838Z" }, + { url = "https://files.pythonhosted.org/packages/3b/dc/d90cb1790f8cec9b4878d278ad9faf7c8f893189ce0f855304fd704fc274/tree_sitter_javascript-0.25.0-cp310-abi3-win_amd64.whl", hash = "sha256:e5ed840f5bd4a3f0272e441d19429b26eedc257abe5574c8546da6b556865e3c", size = 62975, upload-time = "2025-09-01T07:13:42.828Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1f/f9eba1038b7d4394410f3c0a6ec2122b590cd7acb03f196e52fa57ebbe72/tree_sitter_javascript-0.25.0-cp310-abi3-win_arm64.whl", hash = "sha256:622a69d677aa7f6ee2931d8c77c981a33f0ebb6d275aa9d43d3397c879a9bb0b", size = 61668, upload-time = "2025-09-01T07:13:43.803Z" }, +] + +[[package]] +name = "tree-sitter-json" +version = "0.24.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/29/e92df6dca3a6b2ab1c179978be398059817e1173fbacd47e832aaff3446b/tree_sitter_json-0.24.8.tar.gz", hash = "sha256:ca8486e52e2d261819311d35cf98656123d59008c3b7dcf91e61d2c0c6f3120e", size = 8155, upload-time = "2024-11-11T06:05:00.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/41/84866232980fb3cf0cff46f5af2dbb9bfa3324b32614c6a9af3d08926b72/tree_sitter_json-0.24.8-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:59ac06c6db1877d0e2076bce54a5fddcdd2fc38ca778905662e80fa9ffcea2ab", size = 8718, upload-time = "2024-11-11T06:04:49.779Z" }, + { url = "https://files.pythonhosted.org/packages/5c/31/102c15948d97b135611d6a995c97a3933c0e9745f25737723977f58e142c/tree_sitter_json-0.24.8-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:62b4c45b561db31436a81a3f037f71ec29049f4fc9bf5269b6ec3ebaaa35a1cd", size = 9163, upload-time = "2024-11-11T06:04:51.275Z" }, + { url = "https://files.pythonhosted.org/packages/28/64/aa44ea2f3d2e76ec086ce83902eb26b2ed0a92d3fd5e2714c9cb007e90d1/tree_sitter_json-0.24.8-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8627f7d375fda9fc193ebee368c453f374f65c2f25c58b6fea4e6b49a7fccbc", size = 17726, upload-time = "2024-11-11T06:04:52.732Z" }, + { url = "https://files.pythonhosted.org/packages/77/08/10001992526670e0d6f24c571b179f0ece90e5e014a4b98a3ce076884f32/tree_sitter_json-0.24.8-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85cca779872f7278f3a74eb38533d34b9c4de4fd548615e3361fa64fe350ad0a", size = 17236, upload-time = "2024-11-11T06:04:54.189Z" }, + { url = "https://files.pythonhosted.org/packages/92/64/908e9e0bd84fe3c81c564115d3bbe0e49b0e152784bbaf153d749d00bbe6/tree_sitter_json-0.24.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:deeb45850dcc52990fbb52c80196492a099e3fa3512d928a390a91cf061068cc", size = 16071, upload-time = "2024-11-11T06:04:55.628Z" }, + { url = "https://files.pythonhosted.org/packages/53/df/31daab1eedb445bef208a04fc35428de3afe2b37075fec84d7737e1c69de/tree_sitter_json-0.24.8-cp39-abi3-win_amd64.whl", hash = "sha256:e4849a03cd7197267b2688a4506a90a13568a8e0e8588080bd0212fcb38974e3", size = 11457, upload-time = "2024-11-11T06:04:57.698Z" }, + { url = "https://files.pythonhosted.org/packages/6c/3d/902d2f3125b6b90cebf404b63ca775bc6d82071ccc76c0d10fabfeb2febe/tree_sitter_json-0.24.8-cp39-abi3-win_arm64.whl", hash = "sha256:591e0096c882d12668b88f30d3ca6f85b9db3406910eaaab6afb6b17d65367dd", size = 10174, upload-time = "2024-11-11T06:04:59.309Z" }, +] + +[[package]] +name = "tree-sitter-markdown" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/87/8f705d8f99337c8a691bcc8c22d89ddd323eb2b860a78ae2e894b9f7ade1/tree_sitter_markdown-0.5.1.tar.gz", hash = "sha256:6c69d7270a7e09be8988ced44584c09a6a4f541cea0dc394dd1c1a5ac3b5601d", size = 250138, upload-time = "2025-09-16T17:12:11.732Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/73/b5f88217a526f61080ddd71d554cff6a01ea23fffa584ad9de41ee8d1fe5/tree_sitter_markdown-0.5.1-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:f00ce3f48f127377983859fcb93caf0693cbc7970f8c41f1e2bd21e4d56bdfd8", size = 139706, upload-time = "2025-09-16T17:12:03.738Z" }, + { url = "https://files.pythonhosted.org/packages/6d/9b/65eb5e6a8d7791174644854437d35849d9b4e4ed034d54d2c78810eaf1a6/tree_sitter_markdown-0.5.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1ec4cc5d7b0d188bad22247501ab13663bb1bf1a60c2c020a22877fabce8daa9", size = 147540, upload-time = "2025-09-16T17:12:04.955Z" }, + { url = "https://files.pythonhosted.org/packages/24/d5/4152d00829c8643243f65b67a5485248661824f15e1868e14e54f03c2069/tree_sitter_markdown-0.5.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:727242a70c46222092eba86c102301646f21ba32aee221f4b1f70e2020755e81", size = 187851, upload-time = "2025-09-16T17:12:05.813Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c1/994001c5a51d09e9da7236e01a855d3d49437a47fa8669f1d5e9ed60e64f/tree_sitter_markdown-0.5.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0b2fde19e692bb90e300d9788887528c624b659c794de6337f8193396de4399", size = 187563, upload-time = "2025-09-16T17:12:06.929Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d1/1f2ba1ae11568639f133c45c7a697e4e9277d6cc26a66c0caee62c11d1c2/tree_sitter_markdown-0.5.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:13da82db04cec7910b6afd4a67d02da9ef402df8d56fc6ed85e00584af1730ee", size = 185478, upload-time = "2025-09-16T17:12:08.126Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c8/8218482d56b78755cdc20816a28754145cb1767e1e7e0ddde5988547ab86/tree_sitter_markdown-0.5.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b8a8a04a5d942c177cc590ec40074fcf3658f3a7c0a3388a8575990003665d8c", size = 184922, upload-time = "2025-09-16T17:12:08.937Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ca/423600960b91c3aba6f2202ad4c430b5401e652d51a73a59769375c2b4ea/tree_sitter_markdown-0.5.1-cp39-abi3-win_amd64.whl", hash = "sha256:b1b0e4cbcf5a7b85005f1e9266fc2ed9b649b41a6048f3b1abae3612368d97a6", size = 142519, upload-time = "2025-09-16T17:12:10.027Z" }, + { url = "https://files.pythonhosted.org/packages/93/f5/327dd7fa42ae39796a8853685c40a8ac968585260094c581047270cbc851/tree_sitter_markdown-0.5.1-cp39-abi3-win_arm64.whl", hash = "sha256:2296ef53a757d8f5b848616706d0518e04d487bc7748bd05755d4a3a65711542", size = 137166, upload-time = "2025-09-16T17:12:10.858Z" }, +] + +[[package]] +name = "tree-sitter-python" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/8b/c992ff0e768cb6768d5c96234579bf8842b3a633db641455d86dd30d5dac/tree_sitter_python-0.25.0.tar.gz", hash = "sha256:b13e090f725f5b9c86aa455a268553c65cadf325471ad5b65cd29cac8a1a68ac", size = 159845, upload-time = "2025-09-11T06:47:58.159Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/64/a4e503c78a4eb3ac46d8e72a29c1b1237fa85238d8e972b063e0751f5a94/tree_sitter_python-0.25.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:14a79a47ddef72f987d5a2c122d148a812169d7484ff5c75a3db9609d419f361", size = 73790, upload-time = "2025-09-11T06:47:47.652Z" }, + { url = "https://files.pythonhosted.org/packages/e6/1d/60d8c2a0cc63d6ec4ba4e99ce61b802d2e39ef9db799bdf2a8f932a6cd4b/tree_sitter_python-0.25.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:480c21dbd995b7fe44813e741d71fed10ba695e7caab627fb034e3828469d762", size = 76691, upload-time = "2025-09-11T06:47:49.038Z" }, + { url = "https://files.pythonhosted.org/packages/aa/cb/d9b0b67d037922d60cbe0359e0c86457c2da721bc714381a63e2c8e35eba/tree_sitter_python-0.25.0-cp310-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:86f118e5eecad616ecdb81d171a36dde9bef5a0b21ed71ea9c3e390813c3baf5", size = 108133, upload-time = "2025-09-11T06:47:50.499Z" }, + { url = "https://files.pythonhosted.org/packages/40/bd/bf4787f57e6b2860f3f1c8c62f045b39fb32d6bac4b53d7a9e66de968440/tree_sitter_python-0.25.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be71650ca2b93b6e9649e5d65c6811aad87a7614c8c1003246b303f6b150f61b", size = 110603, upload-time = "2025-09-11T06:47:51.985Z" }, + { url = "https://files.pythonhosted.org/packages/5d/25/feff09f5c2f32484fbce15db8b49455c7572346ce61a699a41972dea7318/tree_sitter_python-0.25.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e6d5b5799628cc0f24691ab2a172a8e676f668fe90dc60468bee14084a35c16d", size = 108998, upload-time = "2025-09-11T06:47:53.046Z" }, + { url = "https://files.pythonhosted.org/packages/75/69/4946da3d6c0df316ccb938316ce007fb565d08f89d02d854f2d308f0309f/tree_sitter_python-0.25.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:71959832fc5d9642e52c11f2f7d79ae520b461e63334927e93ca46cd61cd9683", size = 107268, upload-time = "2025-09-11T06:47:54.388Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a2/996fc2dfa1076dc460d3e2f3c75974ea4b8f02f6bc925383aaae519920e8/tree_sitter_python-0.25.0-cp310-abi3-win_amd64.whl", hash = "sha256:9bcde33f18792de54ee579b00e1b4fe186b7926825444766f849bf7181793a76", size = 76073, upload-time = "2025-09-11T06:47:55.773Z" }, + { url = "https://files.pythonhosted.org/packages/07/19/4b5569d9b1ebebb5907d11554a96ef3fa09364a30fcfabeff587495b512f/tree_sitter_python-0.25.0-cp310-abi3-win_arm64.whl", hash = "sha256:0fbf6a3774ad7e89ee891851204c2e2c47e12b63a5edbe2e9156997731c128bb", size = 74169, upload-time = "2025-09-11T06:47:56.747Z" }, +] + +[[package]] +name = "tree-sitter-regex" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/92/1767b833518d731b97c07cf616ea15495dcc0af584aa0381657be4ec446d/tree_sitter_regex-0.25.0.tar.gz", hash = "sha256:5d29111b3f27d4afb31496476d392d1f562fe0bfe954e8968f1d8683424fc331", size = 22156, upload-time = "2025-09-13T05:00:18.699Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/b4/12e9ba02bab4ce13d1875f6585c3f2a5816233104d1507ea118950a4f7eb/tree_sitter_regex-0.25.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:3fa11bbd76b29ac8ca2dbf85ad082f9b18ae6352251d805eb2d4191e1706a9d5", size = 13267, upload-time = "2025-09-13T05:00:10.847Z" }, + { url = "https://files.pythonhosted.org/packages/71/06/6b4f995f61952572a94bcfce12d43fc580226551fab9dd0aac4e94465f38/tree_sitter_regex-0.25.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:df5713649b89c5758649398053c306c41565f22a6f267cb5ec25596504bcf012", size = 13646, upload-time = "2025-09-13T05:00:12.149Z" }, + { url = "https://files.pythonhosted.org/packages/43/61/d94d889ee415805e5d64fc5163e7e2996975bb2c40d13f547efae3e7e37d/tree_sitter_regex-0.25.0-cp310-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cdd92400fd9d8229e584c55e12410251561f0d47eea49db17805e2f64a8b2490", size = 24691, upload-time = "2025-09-13T05:00:13.037Z" }, + { url = "https://files.pythonhosted.org/packages/00/a8/09dd698a9ac2b3d3139a936742b41ec1263f0b86d32ad68f4695871c8860/tree_sitter_regex-0.25.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cceab1c14deeec9c5899babcb2b7942f0607b4355e66eab4083514f644f1bd52", size = 26741, upload-time = "2025-09-13T05:00:14.182Z" }, + { url = "https://files.pythonhosted.org/packages/d7/bf/985e226c9a9f5ae895ff1a2cbc69531589a7d74acac49b2710ec89d53d80/tree_sitter_regex-0.25.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:253436be178150ca4a0603720e0c246e08b5bdd2dc6df313667d97e6c0fce846", size = 25758, upload-time = "2025-09-13T05:00:14.994Z" }, + { url = "https://files.pythonhosted.org/packages/b8/89/c6a6817e94a7deb61770a21e590a46791778ceed053ba4afbfb095488a23/tree_sitter_regex-0.25.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:883eacc46fd7eaffc328efd5865f1fe8825711892d3a89fccc2c414b061e806d", size = 24575, upload-time = "2025-09-13T05:00:16.081Z" }, + { url = "https://files.pythonhosted.org/packages/d0/5e/04e87eb155875f27355703ac7ab703090e30ad9aac6e003ef5c40820ee98/tree_sitter_regex-0.25.0-cp310-abi3-win_amd64.whl", hash = "sha256:f0f2ebf9a6bb5d0d0da2a8ac51d7e5a985b87cdb24d86db5ddc6a58baf115d5d", size = 15684, upload-time = "2025-09-13T05:00:16.865Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d3/1f37c79dc18cc3c7521fdb51b614d29a36628d2afdc2cac2680967e703a6/tree_sitter_regex-0.25.0-cp310-abi3-win_arm64.whl", hash = "sha256:d5a36150daa452f8aec1c2d6d1f2d26255dc05d1490f9618b14c12a6a648cda4", size = 14525, upload-time = "2025-09-13T05:00:17.673Z" }, +] + +[[package]] +name = "tree-sitter-rust" +version = "0.24.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/87/75cbd22b927267d310f76cca1ab3c1d9d41035dfa3eb9cc95f96ee199440/tree_sitter_rust-0.24.2.tar.gz", hash = "sha256:54fb02a5911e345308b405174465112479f56dc39e3f1e7744d7568595f00db9", size = 339341, upload-time = "2026-03-27T21:08:55.629Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/24/2b2d33af5e27c84a4fde4e8cd2594bb4ab1e1cf48756a9f40dadc84956cc/tree_sitter_rust-0.24.2-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:3620cfd12340efa43082d45df76349ff511893a9c361da2f8d6d51e307020a59", size = 129507, upload-time = "2026-03-27T21:08:47.585Z" }, + { url = "https://files.pythonhosted.org/packages/78/2a/cf39f881a545360b5a86bb1accba1f4acc713daab01fb9edd35b6e84f473/tree_sitter_rust-0.24.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:01a46622735498493f29f3e628a90de95c96a07bfbeb88996243eb986b1cee36", size = 136812, upload-time = "2026-03-27T21:08:48.761Z" }, + { url = "https://files.pythonhosted.org/packages/ca/45/a051bbd3045a61182dde25b93ae9a33d2677c935b16952283e12eaf46051/tree_sitter_rust-0.24.2-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e033c5a93b57c88e0a835880de39fc802909ff69f57aaff6000211c196ea5190", size = 164706, upload-time = "2026-03-27T21:08:49.605Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f6/a5a146df5c0a5daea3ffcd5d7245775fe7f084357770d5a313dd6245ae78/tree_sitter_rust-0.24.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9d76d1208c3638b871236090759dfc13d478921320653a6c9da5336e7c58f65a", size = 170310, upload-time = "2026-03-27T21:08:50.424Z" }, + { url = "https://files.pythonhosted.org/packages/95/a8/f85b1ca75e01361ca5f92d226593ca4857cea49551b9f6c8fa6fc08ea917/tree_sitter_rust-0.24.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:87930163a462408c49ab62c667e74029bc26b4cc7123dd1bdc7352215786c64a", size = 168668, upload-time = "2026-03-27T21:08:51.404Z" }, + { url = "https://files.pythonhosted.org/packages/a2/e1/3519f866a4679ca36acd9f5a06a779ecb8a92b18887c5546458d521df557/tree_sitter_rust-0.24.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:da2b86099028fd42c6cd32878b7b16b01f8aac0f7b0e98742b7fa6bc3cf09b89", size = 162403, upload-time = "2026-03-27T21:08:52.588Z" }, + { url = "https://files.pythonhosted.org/packages/34/71/7ef609894dbfe5699eb16f7471f9b8af1d958d8ba3e29c238d7607e8cb47/tree_sitter_rust-0.24.2-cp39-abi3-win_amd64.whl", hash = "sha256:4529c125d928882ddfb879fdc6bc0704913261ecc078b6fa7902559e0daf200d", size = 129422, upload-time = "2026-03-27T21:08:54.031Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d8/050a781172745bc345f98abb7c56e72022ea0790f8e793de981c83c2ef15/tree_sitter_rust-0.24.2-cp39-abi3-win_arm64.whl", hash = "sha256:66ba90f61bd54f4c4f5d30434957daf64507c16b0313df76becb37d63f70a227", size = 128245, upload-time = "2026-03-27T21:08:54.803Z" }, +] + +[[package]] +name = "tree-sitter-sql" +version = "0.3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/5c/3d10387f779f36835486167253682f61d5f4fd8336b7001da1ac7d78f31c/tree_sitter_sql-0.3.11.tar.gz", hash = "sha256:700b93be2174c3c83d174ec3e10b682f72a4fb451f0076c7ce5012f1d5a76cbc", size = 834454, upload-time = "2025-10-01T13:44:15.913Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/68/bb80073915dfe1b38935451bc0d65528666c126b2d5878e7140ef9bf9f8a/tree_sitter_sql-0.3.11-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cf1b0c401756940bf47544ad7c4cc97373fc0dac118f821820953e7015a115e3", size = 322035, upload-time = "2025-10-01T13:44:07.497Z" }, + { url = "https://files.pythonhosted.org/packages/05/45/b2bd5f9919ea15c4ae90a156999101ebd4caa4036babe54efaf9d3e77d55/tree_sitter_sql-0.3.11-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:a33cd6880ab2debef036f80365c32becb740ec79946805598488732b6c515fff", size = 341635, upload-time = "2025-10-01T13:44:08.961Z" }, + { url = "https://files.pythonhosted.org/packages/8e/96/7cee5661aa897e5d1a67499944ea5cf8a148953c1dc07a3059a50db8cb56/tree_sitter_sql-0.3.11-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:344e99b59c8c8d72f7154041e9d054400f4a3fccc16c2c96ac106dde0e7f8d0c", size = 381217, upload-time = "2025-10-01T13:44:10.211Z" }, + { url = "https://files.pythonhosted.org/packages/1d/c1/eec7c09a9c94436ea4c56d096feba815e42b209b3d41a17532f99ecf0c67/tree_sitter_sql-0.3.11-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5128b12f71ac0f5ebcc607f67a62cdc56a187c1a5ba7553feeb9c5f6f9bc3c72", size = 380606, upload-time = "2025-10-01T13:44:11.135Z" }, + { url = "https://files.pythonhosted.org/packages/94/1d/06e9598799bd119e56f6e431d42c2f3a5c6dee858a5b6ad7633cc4d670aa/tree_sitter_sql-0.3.11-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:03cc164fcf7b1f711e7d939aeb4d1f62c76f4162e081c70b860b4fcd91806a38", size = 380862, upload-time = "2025-10-01T13:44:12.072Z" }, + { url = "https://files.pythonhosted.org/packages/52/e9/a7afd7f68ce165c040ce50e67bb05553784a8e17f37e057405d693fc869d/tree_sitter_sql-0.3.11-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:0e22ea8de690dd9960d8c0c36c4cd25417b084e1e29c91ac0235fbdb3abb4664", size = 379447, upload-time = "2025-10-01T13:44:13.062Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b3/57ff42dadd33c06fabe6c725de50e1625e1060f1571cc21a9260febadc1f/tree_sitter_sql-0.3.11-cp310-abi3-win_amd64.whl", hash = "sha256:c57b877702d218c0856592d33320c02b2dc8411d8820b3bf7b81be86c54fa0bb", size = 343550, upload-time = "2025-10-01T13:44:13.988Z" }, + { url = "https://files.pythonhosted.org/packages/77/60/f10b8551f435d57a4748820ee30e66df2682820b2972375c2b89d2e5fb10/tree_sitter_sql-0.3.11-cp310-abi3-win_arm64.whl", hash = "sha256:8a1e42f0a2c9b01b23074708ecf5b8d21b9a0440e3dff279d8cf466cdf1a877e", size = 333547, upload-time = "2025-10-01T13:44:14.893Z" }, +] + +[[package]] +name = "tree-sitter-toml" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/59/b9/03ee757ac375e77186ea112c14fcf31e0ca70b27b6388d93dcceef61f029/tree_sitter_toml-0.7.0.tar.gz", hash = "sha256:29e257612fa8f0c1fcbc4e7e08ddc561169f1725265302e64d81086354144a70", size = 16803, upload-time = "2024-12-03T05:03:46.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/4d/1e00a5cd8dba09e340b25aa60a3eaeae584ff5bc5d93b0777169d6741ee5/tree_sitter_toml-0.7.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b9ae5c3e7c5b6bb05299dd73452ceafa7fa0687d5af3012332afa7757653b676", size = 14755, upload-time = "2024-12-03T05:03:39.973Z" }, + { url = "https://files.pythonhosted.org/packages/92/20/ac8a20805339105fe0bbb6beaa99dbbd1159647760ddd786142364e0b7f2/tree_sitter_toml-0.7.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:18be09538e9775cddc0290392c4e2739de2201260af361473ca60b5c21f7bd22", size = 15201, upload-time = "2024-12-03T05:03:40.871Z" }, + { url = "https://files.pythonhosted.org/packages/36/cf/7bae8e20310e7cc763ae407599e6130b819f91ad5197e210a56f697f15d8/tree_sitter_toml-0.7.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a045e0acfcf91b7065066f7e51ea038ed7385c1e35e7e8fae18f252d3f8adb8c", size = 30855, upload-time = "2024-12-03T05:03:41.83Z" }, + { url = "https://files.pythonhosted.org/packages/7d/49/51f2fa25a3ff4d45af1be8cbf7a3d733fb6a390b2763cfa00892fffe90bf/tree_sitter_toml-0.7.0-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a2f8cf9d73f07b6628093b35e5c5fbac039247e32cb075eaa5289a5914e73af", size = 29741, upload-time = "2024-12-03T05:03:42.577Z" }, + { url = "https://files.pythonhosted.org/packages/4d/30/dd94ed1ab0bc3198e16ed2140a6f4d2474c1cd561d8c6847ab269af73654/tree_sitter_toml-0.7.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:860ffa4513b2dc3083d8e412bd815a350b0a9490624b37e7c8f6ed5c6f9ce63c", size = 30498, upload-time = "2024-12-03T05:03:43.447Z" }, + { url = "https://files.pythonhosted.org/packages/a2/dd/0681d43aa09dd161565858bcfdd4402c8d10259f142de734448f5ce17418/tree_sitter_toml-0.7.0-cp39-abi3-win_amd64.whl", hash = "sha256:2760a04f06937b01b1562a2135cd7e8207e399e73ef75bbebc77e37b1ad3b15d", size = 16756, upload-time = "2024-12-03T05:03:44.227Z" }, + { url = "https://files.pythonhosted.org/packages/17/e4/cce587001e620f1972e70aeabc1b38893a85681be9ec5a64e4be9ce17410/tree_sitter_toml-0.7.0-cp39-abi3-win_arm64.whl", hash = "sha256:fd00fd8a51c65aa19c40539431cb1773d87c30af5757b4041fa6c229058420b4", size = 15651, upload-time = "2024-12-03T05:03:45.261Z" }, +] + +[[package]] +name = "tree-sitter-xml" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/ba/77a92dbb4dfb374fb99863a07f938de7509ceeaa74139933ac2bd306eeb1/tree_sitter_xml-0.7.0.tar.gz", hash = "sha256:ab0ff396f20230ad8483d968151ce0c35abe193eb023b20fbd8b8ce4cf9e9f61", size = 54635, upload-time = "2024-11-13T17:27:01.655Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/1d/6b8974c493973c0c9df2bbf220a1f0a96fa785da81a5a13461faafd1441c/tree_sitter_xml-0.7.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cc3e516d4c1e0860fb22172c172148debb825ba638971bc48bad15b22e5b0bae", size = 35404, upload-time = "2024-11-13T17:26:51.989Z" }, + { url = "https://files.pythonhosted.org/packages/75/f5/31013d04c4e3b9a55e90168cc222a601c84235ba4953a5a06b5cdf8353c4/tree_sitter_xml-0.7.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:0674fdf4cc386e4d323cb287d3b072663de0f20a9e9af5d5e09821aae56a9e5c", size = 35488, upload-time = "2024-11-13T17:26:53.526Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e6/e7493217f950a7c5969e3f3f057664142fa948abefd2dba5acea25719d55/tree_sitter_xml-0.7.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c0fe5f2d6cc09974c8375c8ea9b24909f493b5bf04aacdc4c694b5d2ae6b040", size = 74199, upload-time = "2024-11-13T17:26:55.069Z" }, + { url = "https://files.pythonhosted.org/packages/94/27/1dd6815592489de51fa7b5fffc1160cd385ade7fa06f07b998742ac18020/tree_sitter_xml-0.7.0-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd3209516a4d84dff90bc91d2ad2ce246de8504cede4358849687fa8e71536e7", size = 76244, upload-time = "2024-11-13T17:26:56.655Z" }, + { url = "https://files.pythonhosted.org/packages/20/10/2e4e84c50b2175cb53d255ef154aa893cb82cc9d035d7a1a73be9d2d2db4/tree_sitter_xml-0.7.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:87578e15fa55f44ecd9f331233b6f8a2cbde3546b354c830ecb862a632379455", size = 75112, upload-time = "2024-11-13T17:26:57.729Z" }, + { url = "https://files.pythonhosted.org/packages/ae/91/77c348568bccb179eca21062c923f6f54026900b09fe0cf1aae89d78a0c8/tree_sitter_xml-0.7.0-cp39-abi3-win_amd64.whl", hash = "sha256:9ba2dafc6ce9feaf4ccc617d3aeea57f8e0ca05edad34953e788001ebff79133", size = 36558, upload-time = "2024-11-13T17:26:58.702Z" }, + { url = "https://files.pythonhosted.org/packages/be/cc/6b4de230770d7be87b2a415583121ac565ce1ff7d9a1ad7fec11f8e613fc/tree_sitter_xml-0.7.0-cp39-abi3-win_arm64.whl", hash = "sha256:fc759f710a8fd7a01c23e2d7cb013679199045bea3dc0e5151650a11322aaf40", size = 34610, upload-time = "2024-11-13T17:27:00.187Z" }, +] + +[[package]] +name = "tree-sitter-yaml" +version = "0.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/b6/941d356ac70c90b9d2927375259e3a4204f38f7499ec6e7e8a95b9664689/tree_sitter_yaml-0.7.2.tar.gz", hash = "sha256:756db4c09c9d9e97c81699e8f941cb8ce4e51104927f6090eefe638ee567d32c", size = 84882, upload-time = "2025-10-07T14:40:36.071Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/29/c0b8dbff302c49ff4284666ffb6f2f21145006843bb4c3a9a85d0ec0b7ae/tree_sitter_yaml-0.7.2-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:7e269ddcfcab8edb14fbb1f1d34eed1e1e26888f78f94eedfe7cc98c60f8bc9f", size = 43898, upload-time = "2025-10-07T14:40:29.486Z" }, + { url = "https://files.pythonhosted.org/packages/18/0d/15a5add06b3932b5e4ce5f5e8e179197097decfe82a0ef000952c8b98216/tree_sitter_yaml-0.7.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:0807b7966e23ddf7dddc4545216e28b5a58cdadedcecca86b8d8c74271a07870", size = 44691, upload-time = "2025-10-07T14:40:30.369Z" }, + { url = "https://files.pythonhosted.org/packages/72/92/c4b896c90d08deb8308fadbad2210fdcc4c66c44ab4292eac4e80acb4b61/tree_sitter_yaml-0.7.2-cp310-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f1a5c60c98b6c4c037aae023569f020d0c489fad8dc26fdfd5510363c9c29a41", size = 91430, upload-time = "2025-10-07T14:40:31.16Z" }, + { url = "https://files.pythonhosted.org/packages/89/59/61f1fed31eb6d46ff080b8c0d53658cf29e10263f41ef5fe34768908037a/tree_sitter_yaml-0.7.2-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88636d19d0654fd24f4f242eaaafa90f6f5ebdba8a62e4b32d251ed156c51a2a", size = 92428, upload-time = "2025-10-07T14:40:31.954Z" }, + { url = "https://files.pythonhosted.org/packages/e3/62/a33a04d19b7f9a0ded780b9c9fcc6279e37c5d00b89b00425bb807a22cc2/tree_sitter_yaml-0.7.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1d2e8f0bb14aa4537320952d0f9607eef3021d5aada8383c34ebeece17db1e06", size = 90580, upload-time = "2025-10-07T14:40:33.037Z" }, + { url = "https://files.pythonhosted.org/packages/6c/e7/9525defa7b30792623f56b1fba9bbba361752348875b165b8975b87398fd/tree_sitter_yaml-0.7.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:74ca712c50fc9d7dbc68cb36b4a7811d6e67a5466b5a789f19bf8dd6084ef752", size = 90455, upload-time = "2025-10-07T14:40:33.778Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d6/8d1e1ace03db3b02e64e91daf21d1347941d1bbecc606a5473a1a605250d/tree_sitter_yaml-0.7.2-cp310-abi3-win_amd64.whl", hash = "sha256:7587b5ca00fc4f9a548eff649697a3b395370b2304b399ceefa2087d8a6c9186", size = 45514, upload-time = "2025-10-07T14:40:34.562Z" }, + { url = "https://files.pythonhosted.org/packages/d8/c7/dcf3ea1c4f5da9b10353b9af4455d756c92d728a8f58f03c480d3ef0ead5/tree_sitter_yaml-0.7.2-cp310-abi3-win_arm64.whl", hash = "sha256:f63c227b18e7ce7587bce124578f0bbf1f890ac63d3e3cd027417574273642c4", size = 44065, upload-time = "2025-10-07T14:40:35.337Z" }, +] + [[package]] name = "triton" version = "3.5.1" @@ -7532,6 +7873,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, ] +[[package]] +name = "uc-micro-py" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, +] + [[package]] name = "uncalled-for" version = "0.2.0" @@ -7608,6 +7958,7 @@ dependencies = [ { name = "randomname" }, { name = "rich" }, { name = "selectolax" }, + { name = "textual", extra = ["syntax"] }, { name = "truststore" }, { name = "typer" }, ] @@ -7688,7 +8039,7 @@ requires-dist = [ { name = "langchain-chroma", specifier = ">=1.0.0" }, { name = "langchain-community", specifier = ">=0.4.1" }, { name = "langchain-google-genai", specifier = ">=4.2" }, - { name = "langchain-mcp-adapters", specifier = ">=0.1.12,<0.2" }, + { name = "langchain-mcp-adapters", specifier = "~=0.2.2" }, { name = "langchain-ollama", specifier = ">=1.0.0" }, { name = "langchain-openai", specifier = ">=1.0.1" }, { name = "langchain-text-splitters", specifier = ">=1.0.0" }, @@ -7709,9 +8060,10 @@ requires-dist = [ { name = "python-pptx", marker = "extra == 'office-readers'", specifier = ">=1.0.2" }, { name = "pyyaml", specifier = ">=6.0.3" }, { name = "randomname", specifier = ">=0.2.1,<0.3" }, - { name = "rich", specifier = ">=13.9.4,<14.0" }, + { name = "rich", specifier = ">=14.2.0,<15.0" }, { name = "scipy", marker = "extra == 'metric-plots'", specifier = ">=1.16.2,<2.0.0" }, { name = "selectolax", specifier = ">=0.4.0,<0.5" }, + { name = "textual", extras = ["syntax"], specifier = ">=8.2.8,<9.0" }, { name = "torch", marker = "extra == 'fm'", specifier = ">=2.9.0" }, { name = "trafilatura", marker = "extra == 'lammps'", specifier = ">=1.6.1,<1.7" }, { name = "truststore", specifier = ">=0.10.4,<1.0" },