fix(tui): extract @token text correctly in on_input_submitted suggestions query
CI / push-validation (pull_request) Successful in 1m8s
CI / helm (pull_request) Successful in 1m13s
CI / build (pull_request) Successful in 1m59s
CI / lint (pull_request) Failing after 2m36s
CI / typecheck (pull_request) Successful in 2m36s
CI / quality (pull_request) Successful in 2m39s
CI / security (pull_request) Successful in 2m46s
CI / integration_tests (pull_request) Successful in 6m11s
CI / unit_tests (pull_request) Successful in 7m22s
CI / coverage (pull_request) Has been skipped
CI / docker (pull_request) Has been skipped
CI / status-check (pull_request) Failing after 6s
CI / push-validation (pull_request) Successful in 1m8s
CI / helm (pull_request) Successful in 1m13s
CI / build (pull_request) Successful in 1m59s
CI / lint (pull_request) Failing after 2m36s
CI / typecheck (pull_request) Successful in 2m36s
CI / quality (pull_request) Successful in 2m39s
CI / security (pull_request) Successful in 2m46s
CI / integration_tests (pull_request) Successful in 6m11s
CI / unit_tests (pull_request) Successful in 7m22s
CI / coverage (pull_request) Has been skipped
CI / docker (pull_request) Has been skipped
CI / status-check (pull_request) Failing after 6s
Bug #4741: The TUI on_input_submitted handler passed text.replace("@", "").strip() to suggestions(), e.g. "analyse @proj" -> "analyse proj", producing garbage fuzzy matches because all @ characters were stripped and surrounding words polluted the query. Fix uses re.findall(r"@(\S+)", text)[-1] which extracts only the last @token as the suggestion query, without surrounding words. Changes: - Replace text.replace("@", "").strip() with regex @token extraction in src/cleveragents/tui/app.py on_input_submitted handler - Add TDD BDD regression test covering 4 input scenarios: single-token (@proj), category-prefixed (@project:doc), multi-token (@actor:x @tool:y -> tool:y), and standalone (@skill:name) - Update CHANGELOG.md under [Unreleased]/Fixed - Update CONTRIBUTORS.md with contribution entry ISSUES CLOSED: #4741
This commit is contained in:
@@ -5,6 +5,13 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
Changed `wf10_batch.robot` to be less likely to create files, and
|
||||
`plan_generation_graph.robot` to give more test answers.
|
||||
|
||||
- **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.
|
||||
|
||||
- Hardened the TDD bug-fix quality gate for issue #629: PR parsing now
|
||||
requires whole-word closing keywords (avoids false positives like
|
||||
"prefixes #12"), TDD bug tag discovery now uses exact token matching
|
||||
|
||||
@@ -46,3 +46,4 @@ Below are some of the specific details of various contributions.
|
||||
* HAL 9000 has contributed the PyYAML security upgrade (PR #11012 / issue #9055): added `pyyaml>=6.0.3` dependency constraint to address known YAML parsing vulnerabilities.
|
||||
* 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).
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
"""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 <input> / <expected_query> pair is tested identically.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from types import ModuleType, SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from behave import given, then
|
||||
from behave.runner import Context
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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(__import__("cleveragents.tui.widgets.help_panel_overlay"))
|
||||
importlib.reload(__import__("cleveragents.tui.widgets.persona_bar"))
|
||||
importlib.reload(__import__("cleveragents.tui.widgets.prompt"))
|
||||
importlib.reload(__import__("cleveragents.tui.widgets.reference_picker"))
|
||||
importlib.reload(__import__("cleveragents.tui.widgets.slash_command_overlay"))
|
||||
app_mod = __import__("cleveragents.tui.app")
|
||||
importlib.reload(app_mod)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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()
|
||||
|
||||
# Now install the personas into sys.modules for the app module's imports
|
||||
import cleveragents.tui.persona.registry as _p_reg
|
||||
import cleveragents.tui.persona.state as _p_state
|
||||
|
||||
context._tui_persona_state = persona_state
|
||||
context._tui_cmd_router = mock_router
|
||||
|
||||
app_mod = __import__("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.
|
||||
original_suggestions = app_mod.suggestions
|
||||
|
||||
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"] = MockStatic()
|
||||
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())
|
||||
|
||||
try:
|
||||
app_inst.on_input_submitted(event=MagicMock())
|
||||
except Exception:
|
||||
pass # Many things will fail with incomplete mocks; that's OK.
|
||||
|
||||
context._tui_captured_query = captured_queries[0] if captured_queries else None
|
||||
|
||||
def _cleanup():
|
||||
if hasattr(app_inst, "_widgets"):
|
||||
app_inst._widgets.clear()
|
||||
|
||||
context.add_cleanup(_restore_modules)
|
||||
context.add_cleanup(lambda: (importlib.reload(__import__("cleveragents.tui.app")),))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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."
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
@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".
|
||||
|
||||
@tdd_expected_fail
|
||||
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 "<input>"
|
||||
Then the suggestion query should be "<expected_query>"
|
||||
|
||||
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" |
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Protocol
|
||||
|
||||
@@ -203,10 +204,11 @@ if _TEXTUAL_AVAILABLE:
|
||||
|
||||
preview = 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))
|
||||
conversation.update(preview)
|
||||
|
||||
_ResolvedTuiApp = _TextualCleverAgentsTuiApp
|
||||
|
||||
Reference in New Issue
Block a user