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

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:
2026-05-15 07:38:47 +00:00
parent dbc382f3d9
commit 23e658a5b1
5 changed files with 286 additions and 4 deletions
+7
View File
@@ -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
+1
View File
@@ -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" |
+6 -4
View File
@@ -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