diff --git a/CHANGELOG.md b/CHANGELOG.md index a77559e73..f857d6eab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ Changed `wf10_batch.robot` to be less likely to create files, and ## [Unreleased] +- **fix(tui): extract @token text correctly in on_input_submitted + suggestions query (PR #11004 / issue #4741)** — The TUI handler passed + ``text.replace("@", "").strip()`` to ``suggestions()``, producing garbage + fuzzy matches (e.g. "analyse \@proj" → "analyse proj"). Replaced with + ``re.findall(r"@(\S+)", text)`` that extracts only the last ``@token`` as + the query, returning just the token value without surrounding words. + - **SubplanExecutionService lazy wiring in CLI** (#10268): Wired `subplan_service` from the DI container into `_get_plan_executor()` so `PlanExecutor` can spawn child plans during the Execute phase. When `SubplanService` is available but diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index c33eb0f9b..f7217f12f 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -48,3 +48,4 @@ Below are some of the specific details of various contributions. * HAL 9000 has contributed the DecisionService wiring for PlanExecutor strategize persistence fix (#10813): added decision_service to the PlanExecutor constructor and wired it from the CLI dependency-injection container in `_get_plan_executor()`, plus implemented `_persist_strategy_decisions()` to persist strategy decisions as domain `Decision` objects. * HAL 9000 has contributed the A2A module rename standardization BDD tests (PR #10583 / issue #8615): comprehensive Behave test suite validating that all 22 A2A symbols are properly exported from `cleveragents.a2a`, no legacy ACP references remain in the module source, and documentation uses correct A2A naming conventions — fixing inline imports, unused behave symbols, cross-scenario context dependencies, and missing type annotations. * HAL 9000 has contributed the `ActorSelectionOverlay._render` → `_refresh_display` rename fix (PR #11176 / issue #11039, Epic #8174): renamed `_render()` method to `_refresh_display()` to avoid shadowing Textual's `Widget._render()`, fixing a crash in textual >=1.0 where `get_content_height()` would receive `None` and raise `AttributeError: 'NoneType' object has no attribute 'get_height'`. +* HAL 9000 has contributed the TUI @token text extraction fix for suggestions query (PR #11004 / issue #4741): replaced ``text.replace("@", "").strip()`` with ``re.findall(r"@(\S+)", text)[-1]`` in ``on_input_submitted`` to correctly extract only the last @token as the query, preventing garbage fuzzy matches. Includes TDD BDD regression test suite covering single-token, category-prefixed, multi-token, and standalone-token scenarios (PR #11004). diff --git a/features/steps/tdd_tui_suggestions_query_extraction_4741_steps.py b/features/steps/tdd_tui_suggestions_query_extraction_4741_steps.py new file mode 100644 index 000000000..9a4473789 --- /dev/null +++ b/features/steps/tdd_tui_suggestions_query_extraction_4741_steps.py @@ -0,0 +1,252 @@ +"""Step definitions for TDD issue #4741: @token text extraction for suggestions(). + +Verifies that ``on_input_submitted`` in ``src/cleveragents/tui/app.py`` extracts +only the last ``@token`` from input using regex (``re.findall(r"@(\\S+)", text)``), +instead of the buggy ``text.replace("@", "").strip()``. + +Uses a Scenario Outline so each / pair is tested identically. +""" + +from __future__ import annotations + +import contextlib +import importlib +import shutil +import sys +import tempfile +from pathlib import Path +from types import ModuleType, SimpleNamespace +from typing import Any +from unittest.mock import MagicMock + +from behave import given, then + + +# --------------------------------------------------------------------------- +# Mock Textual +# --------------------------------------------------------------------------- + +_MOCK_TEXTUAL_KEYS = [ + "textual", + "textual.app", + "textual.containers", + "textual.widgets", +] + + +def _build_mock_textual() -> dict[str, ModuleType]: + mock_textual = ModuleType("textual") + mock_app_mod = ModuleType("textual.app") + mock_containers = ModuleType("textual.containers") + mock_widgets = ModuleType("textual.widgets") + + class MockApp: + def __init__(self, *a, **kw): + pass + + class MockVertical: + def __enter__(self): + return self + + def __exit__(self, *a, **kw): + pass + + class MockHeader: + def __init__(self, *args, **kwargs): + pass + + class MockFooter: + def __init__(self, *args, **kwargs): + pass + + class MockStatic: + def __init__(self, *a, **kw): + self._text = "" + + def update(self, text): + self._text = str(text) + + class MockInput: + value: str = "" + + def __init__(self, *a, **kw): + pass + + def consume_text(self): + return SimpleNamespace(text=self.value) + + mock_app_mod.App = MockApp + mock_containers.Vertical = MockVertical + mock_widgets.Header = MockHeader + mock_widgets.Footer = MockFooter + mock_widgets.Static = MockStatic + mock_widgets.Input = MockInput + + return { + "textual": mock_textual, + "textual.app": mock_app_mod, + "textual.containers": mock_containers, + "textual.widgets": mock_widgets, + } + + +def _restore_modules(context): + for key, val in getattr(context, "_tui_saved_modules", {}).items(): + if val is None: + sys.modules.pop(key, None) + else: + sys.modules[key] = val + + importlib.reload( + importlib.import_module("cleveragents.tui.widgets.help_panel_overlay") + ) + importlib.reload(importlib.import_module("cleveragents.tui.widgets.persona_bar")) + importlib.reload(importlib.import_module("cleveragents.tui.widgets.prompt")) + importlib.reload( + importlib.import_module("cleveragents.tui.widgets.reference_picker") + ) + importlib.reload( + importlib.import_module("cleveragents.tui.widgets.slash_command_overlay") + ) + importlib.reload(importlib.import_module("cleveragents.tui.app")) + + +# --------------------------------------------------------------------------- +# Given: set up mock + run on_input_submitted with the captured input text +# --------------------------------------------------------------------------- + + +@given('the TUI on_input_submitted handler processes "{input_text}"') +def step_run_on_input_submitted(context, input_text): + """Install mock Textual, create app, simulate submission, capture query. + + We patch both ``ReferencePickerOverlay.set_suggestions`` and the module-level + ``suggestions()`` so we can inspect the exact argument passed to it. + """ + # Remove saved modules from any previous scenario (Scenario Outline) + _restore_modules(context) if getattr(context, "_tui_saved_modules", None) else None + + mocks = _build_mock_textual() + + context._tui_saved_modules = {} + for key in _MOCK_TEXTUAL_KEYS: + context._tui_saved_modules[key] = sys.modules.pop(key, None) + for key, mod in mocks.items(): + sys.modules[key] = mod + + # Reload widget modules with mocked base classes + from cleveragents.tui.widgets import ( + help_panel_overlay as hp_mod, + persona_bar as pb_mod, + prompt as prompt_mod, + reference_picker as rp_mod, + slash_command_overlay as sco_mod, + ) + + importlib.reload(hp_mod) + importlib.reload(pb_mod) + importlib.reload(prompt_mod) + importlib.reload(rp_mod) + importlib.reload(sco_mod) + + # Install mock persona state and command router before app module reloads + tmp_dir = tempfile.mkdtemp() + context._tui_tmpdir = Path(tmp_dir) + + from cleveragents.tui.persona.registry import PersonaRegistry + from cleveragents.tui.persona.state import PersonaState + + registry = PersonaRegistry(config_dir=Path(tmp_dir)) + registry.ensure_default() + persona_state = PersonaState(registry=registry) + + class MockCommandRouter: + def handle(self, raw, *, session_id): + return f"handled:{raw}" + + mock_router = MockCommandRouter() + + context._tui_persona_state = persona_state + context._tui_cmd_router = mock_router + + app_mod = importlib.import_module("cleveragents.tui.app") + importlib.reload(app_mod) + + # Patch the suggestions function BEFORE instantiating the app, so that when + # on_input_submitted calls it internally, our spy intercepts it. + captured_queries: list[str] = [] + + def _spy_suggestions(query, *, category=None, limit=8): + captured_queries.append(query) + return list( + app_mod.suggestions.__wrapped__.__code__.co_varnames[:limit] + ) # dummy + + # The suggestions function is imported at module level in app.py. We need to + # patch it there, not on reference_parser itself. + def spy(query, **kw): + captured_queries.append(query) + return [] + + app_mod.suggestions = spy + + cls = app_mod._ResolvedTuiApp + + # Build mock widgets dict so query_one works + app_inst = cls(command_router=mock_router, persona_state=persona_state) + + # Manually populate _widgets from the MockApp instance if it exists + if hasattr(app_inst, "_widgets"): + wdict: dict[str, Any] = app_inst._widgets + else: + wdict = {} + + # Create mock widgets that behave correctly + mock_prompt = MagicMock() + mock_prompt.consume_text.return_value = SimpleNamespace(text=input_text) + + mock_picker = MagicMock() + mock_picker.set_suggestions = lambda q, s: None # dummy, we spy suggestions instead + wdict["#reference-picker"] = mock_picker + + mock_prompt_widget = MagicMock() + mock_prompt_widget.consume_text.return_value = SimpleNamespace(text=input_text) + wdict["#prompt"] = mock_prompt_widget + + wdict["#conversation"] = MagicMock() + wdict["#help-panel"] = MagicMock() + wdict["#persona-bar"] = MagicMock() + + # Patch query_one to use our widget dict + app_inst.query_one = lambda sel, *args, **kw: wdict.get(sel, MagicMock()) + + with contextlib.suppress(Exception): + app_inst.on_input_submitted(event=MagicMock()) + + context._tui_captured_query = captured_queries[0] if captured_queries else None + + def _cleanup(): + if hasattr(app_inst, "_widgets"): + app_inst._widgets.clear() + + def _cleanup_tmpdir(d=context._tui_tmpdir): + if d is not None and d.exists(): + shutil.rmtree(str(d), ignore_errors=True) + + context.add_cleanup(_cleanup) + context.add_cleanup(lambda: _restore_modules(context)) + context.add_cleanup(_cleanup_tmpdir) + + +# --------------------------------------------------------------------------- +# Then: assertion on the captured query +# --------------------------------------------------------------------------- + + +@then('the suggestion query should be "{expected}"') +def step_suggestion_query_is_correct(context, expected): + assert context._tui_captured_query == expected, ( + f"Bug #4741: extracted suggestion query was " + f"{context._tui_captured_query!r}, expected {expected!r}. " + f"The old buggy .replace(@).strip() approach produces garbage matches." + ) diff --git a/features/tdd_tui_suggestions_query_extraction_4741.feature b/features/tdd_tui_suggestions_query_extraction_4741.feature new file mode 100644 index 000000000..ac8d0f88a --- /dev/null +++ b/features/tdd_tui_suggestions_query_extraction_4741.feature @@ -0,0 +1,23 @@ +@tdd_issue @tdd_issue_4741 +Feature: TDD Issue #4741 — Extract only the @token text as suggestions() query + + Bug #4741: ``on_input_submitted`` passed ``text.replace("@", "").strip()`` to + ``suggestions()``, e.g. "analyse \@proj" → "analyse proj", producing garbage + fuzzy matches because surrounding words polluted the query. + + The fix replaces it with ``re.findall(r"@(\S+)", text)`` taking the last + match as the query string, so "analyse \@proj" → "proj". + +Scenario Outline: @token extraction produces correct queries for input text + The suggestion query must be the last extracted @token from input text. + With the old buggy code these assertions fail; after the fix they pass. + + Given the TUI on_input_submitted handler processes "" + Then the suggestion query should be "" + + Examples: + | input | expected_query | + | analyse @proj | proj | + | read @project:main-doc | project:main-doc | + | look-up @actor:x @tool:y | tool:y | + | @skill:doc-generator | skill:doc-generator | diff --git a/src/cleveragents/tui/app.py b/src/cleveragents/tui/app.py index 4734ff2cb..a287240f7 100644 --- a/src/cleveragents/tui/app.py +++ b/src/cleveragents/tui/app.py @@ -4,6 +4,7 @@ from __future__ import annotations import importlib import os +import re from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, ClassVar, Protocol @@ -396,10 +397,13 @@ if _TEXTUAL_AVAILABLE: expanded = result.expanded_text if "@" in text: - ref_picker = self.query_one("#reference-picker", ReferencePickerOverlay) - ref_picker.set_suggestions( - text, suggestions(text.replace("@", "").strip()) - ) + matches = re.findall(r"@(\S+)", text) + if matches: + query_text = matches[-1] + ref_picker = self.query_one( + "#reference-picker", ReferencePickerOverlay + ) + ref_picker.set_suggestions(text, suggestions(query_text)) if self._facade is None: # Facade not wired yet — preview only (graceful degradation)