fix(tui): extract @token text correctly in on_input_submitted suggestions query #10911

Merged
HAL9000 merged 5 commits from bugfix/m8-suggestions-query-extraction into master 2026-06-10 11:58:10 +00:00
3 changed files with 334 additions and 3 deletions
@@ -0,0 +1,279 @@
"""Step definitions for tdd_tui_suggestions_query_extraction_4741.feature.
Regression test for issue #4741:
on_input_submitted must extract only the @token text (without the @
sign and without surrounding non-reference words) as the query passed to
suggestions(). Previously, text.replace("@", "").strip() was used, which
corrupted the query by including all non-reference words from the prompt.
"""
from __future__ import annotations
import importlib
import shutil
import sys
import tempfile
from pathlib import Path
from types import ModuleType, SimpleNamespace
from unittest.mock import patch
from behave import given, then, when
# ---------------------------------------------------------------------------
# Mock Textual infrastructure (mirrors tui_app_coverage_steps.py)
# ---------------------------------------------------------------------------
_MOCK_TEXTUAL_KEYS = [
"textual",
"textual.app",
"textual.containers",
"textual.widgets",
]
def _build_mock_textual_4741() -> dict[str, ModuleType]:
"""Build mock textual modules that satisfy the app import gate."""
mock_textual = ModuleType("textual")
mock_textual_app = ModuleType("textual.app")
mock_textual_containers = ModuleType("textual.containers")
mock_textual_widgets = ModuleType("textual.widgets")
class MockApp:
"""Minimal App stand-in for the Textual base class."""
def __init__(self, *args: object, **kwargs: object) -> None:
self._widgets: dict[str, object] = {}
def query_one(self, selector: str, widget_type: type | None = None) -> object:
if selector in self._widgets:
return self._widgets[selector]
if widget_type is not None:
widget = widget_type(id=selector.lstrip("#"))
self._widgets[selector] = widget
return widget
from unittest.mock import MagicMock
return MagicMock()
class MockVertical:
def __init__(self, *args: object, **kwargs: object) -> None:
pass
def __enter__(self) -> MockVertical:
return self
def __exit__(self, *args: object) -> None:
pass
class MockHeader:
def __init__(self, *args: object, **kwargs: object) -> None:
pass
class MockFooter:
def __init__(self, *args: object, **kwargs: object) -> None:
pass
class MockStatic:
def __init__(self, *args: object, **kwargs: object) -> None:
self._text = ""
def update(self, text: str) -> None:
self._text = text
class MockTextArea:
"""Minimal TextArea stand-in for the Textual base class."""
text: str = ""
def __init__(self, *args: object, **kwargs: object) -> None:
self.text = ""
setattr(mock_textual_app, "App", MockApp)
setattr(mock_textual_containers, "Vertical", MockVertical)
setattr(mock_textual_widgets, "Header", MockHeader)
setattr(mock_textual_widgets, "Footer", MockFooter)
setattr(mock_textual_widgets, "Static", MockStatic)
setattr(mock_textual_widgets, "TextArea", MockTextArea)
return {
"textual": mock_textual,
"textual.app": mock_textual_app,
"textual.containers": mock_textual_containers,
"textual.widgets": mock_textual_widgets,
}
def _install_mock_textual_4741(context: object) -> None:
"""Inject mock textual into sys.modules and reload the app module."""
mocks = _build_mock_textual_4741()
context._tui4741_saved_modules: dict[str, ModuleType | None] = {}
for key in _MOCK_TEXTUAL_KEYS:
context._tui4741_saved_modules[key] = sys.modules.pop(key, None)
for key, mod in mocks.items():
sys.modules[key] = mod
# Reload widget modules so they pick up the mock Static/TextArea base class
import cleveragents.tui.widgets.help_panel_overlay as hp_mod
import cleveragents.tui.widgets.persona_bar as pb_mod
import cleveragents.tui.widgets.prompt as prompt_mod
import cleveragents.tui.widgets.reference_picker as rp_mod
import cleveragents.tui.widgets.slash_command_overlay as sco_mod
importlib.reload(hp_mod)
importlib.reload(pb_mod)
importlib.reload(prompt_mod)
importlib.reload(rp_mod)
Outdated
Review

Blocking: Project policy has ZERO tolerance for # type: ignore comments. These appear on the mock attribute assignments (e.g., mock_textual_app.App = MockApp # type: ignore[attr-defined]).

Suggestion: Replace direct attribute assignment with setattr() calls, which Pyright should accept without suppression. For example:

setattr(mock_textual_app, "App", MockApp)

This is the preferred approach for dynamic attribute assignment to mock module objects.

Blocking: Project policy has ZERO tolerance for `# type: ignore` comments. These appear on the mock attribute assignments (e.g., `mock_textual_app.App = MockApp # type: ignore[attr-defined]`). Suggestion: Replace direct attribute assignment with `setattr()` calls, which Pyright should accept without suppression. For example: ```python setattr(mock_textual_app, "App", MockApp) ``` This is the preferred approach for dynamic attribute assignment to mock module objects.
importlib.reload(sco_mod)
import cleveragents.tui.app as app_mod
importlib.reload(app_mod)
context._tui4741_app_mod = app_mod
context._tui4741_mock_static = mocks["textual.widgets"].Static
def _restore_modules_4741(context: object) -> None:
"""Restore original sys.modules and reload the app module."""
for key, val in getattr(context, "_tui4741_saved_modules", {}).items():
if val is None:
sys.modules.pop(key, None)
else:
sys.modules[key] = val
import cleveragents.tui.widgets.help_panel_overlay as hp_mod
import cleveragents.tui.widgets.persona_bar as pb_mod
import cleveragents.tui.widgets.prompt as prompt_mod
import cleveragents.tui.widgets.reference_picker as rp_mod
import cleveragents.tui.widgets.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)
import cleveragents.tui.app as app_mod
importlib.reload(app_mod)
def _make_persona_state_4741(context: object) -> object:
"""Create a real PersonaState backed by a temp directory."""
from cleveragents.tui.persona.registry import PersonaRegistry
from cleveragents.tui.persona.state import PersonaState
tmp = tempfile.mkdtemp()
context._tui4741_tmpdir = tmp
registry = PersonaRegistry(config_dir=Path(tmp))
registry.ensure_default()
return PersonaState(registry=registry)
def _cleanup_tmpdir_4741(context: object) -> None:
tmp = getattr(context, "_tui4741_tmpdir", None)
if tmp:
shutil.rmtree(tmp, ignore_errors=True)
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("the TUI app module is imported with mocked Textual for issue 4741")
def step_import_with_mock_textual_4741(context: object) -> None:
"""Install mock Textual, reload app module, register cleanup."""
_install_mock_textual_4741(context)
context.add_cleanup(lambda: _restore_modules_4741(context))
context.add_cleanup(lambda: _cleanup_tmpdir_4741(context))
# ---------------------------------------------------------------------------
# App setup
# ---------------------------------------------------------------------------
class _FakeCommandRouter4741:
"""Test command router that returns predictable responses."""
def handle(self, raw: str, *, session_id: str) -> str:
return f"handled:{raw}"
@given("a mock command router and persona state for issue 4741")
def step_create_mock_deps_4741(context: object) -> None:
context._tui4741_cmd_router = _FakeCommandRouter4741()
context._tui4741_persona_state = _make_persona_state_4741(context)
@when("I instantiate the TUI app for issue 4741")
def step_instantiate_app_4741(context: object) -> None:
AppClass = context._tui4741_app_mod._ResolvedTuiApp
context._tui4741_app = AppClass(
command_router=context._tui4741_cmd_router,
persona_state=context._tui4741_persona_state,
)
@when("I call on_mount on the app for issue 4741")
def step_call_on_mount_4741(context: object) -> None:
context._tui4741_app.on_mount()
# ---------------------------------------------------------------------------
# Core regression steps: verify correct query extraction
# ---------------------------------------------------------------------------
@when('I submit "{text}" to the app for issue 4741 and capture the suggestions query')
def step_submit_and_capture_query_4741(context: object, text: str) -> None:
"""Submit text and capture the query argument passed to suggestions()."""
from cleveragents.tui.widgets.prompt import PromptInput
captured: list[str] = []
def _fake_suggestions(
query: str, *, category: str | None = None, limit: int = 8
) -> list[str]:
captured.append(query)
return []
prompt = context._tui4741_app.query_one("#prompt", PromptInput)
prompt.value = text
event = SimpleNamespace()
with patch(
"cleveragents.tui.app.suggestions",
side_effect=_fake_suggestions,
):
context._tui4741_app.on_input_submitted(event)
context._tui4741_captured_query = captured[0] if captured else None
@then('the suggestions query should be "{expected}" not "{wrong}"')
def step_assert_suggestions_query_4741(
context: object, expected: str, wrong: str
) -> None:
"""Assert the captured query matches expected and not the wrong (buggy) value."""
actual = context._tui4741_captured_query
assert actual is not None, (
f"suggestions() was not called — no @token found in submitted text. "
f"Expected query: '{expected}'"
)
assert actual == expected, (
f"suggestions() received wrong query.\n"
f" Expected: '{expected}'\n"
f" Got: '{actual}'\n"
f" (The buggy value would have been: '{wrong}')"
)
@then("suggestions was not called for issue 4741")
def step_assert_suggestions_not_called_4741(context: object) -> None:
"""Assert that suggestions() was not called (no @token in prompt)."""
actual = context._tui4741_captured_query
assert actual is None, (
f"suggestions() was unexpectedly called with query: '{actual}'. "
f"Expected it not to be called when no @token is present in the prompt."
)
@@ -0,0 +1,50 @@
@tdd_issue @tdd_issue_4741
Feature: TDD: suggestions() query extraction in on_input_submitted uses correct @token text
Regression test for issue #4741.
The on_input_submitted handler must extract only the @token text (without the @
sign and without surrounding non-reference words) as the query passed to
suggestions(). Previously, text.replace("@", "").strip() was used, which
corrupted the query by including all non-reference words from the prompt.
Background:
Given the TUI app module is imported with mocked Textual for issue 4741
@tdd_issue @tdd_issue_4741
Scenario: Single @token in multi-word prompt uses only the token text as query
Given a mock command router and persona state for issue 4741
When I instantiate the TUI app for issue 4741
And I call on_mount on the app for issue 4741
And I submit "analyse @proj" to the app for issue 4741 and capture the suggestions query
Then the suggestions query should be "proj" not "analyse proj"
@tdd_issue @tdd_issue_4741
Scenario: @token with category prefix passes only the suffix as query
Given a mock command router and persona state for issue 4741
When I instantiate the TUI app for issue 4741
And I call on_mount on the app for issue 4741
And I submit "show @resource:src/main.py" to the app for issue 4741 and capture the suggestions query
Then the suggestions query should be "resource:src/main.py" not "show resource:src/main.py"
@tdd_issue @tdd_issue_4741
Scenario: Multiple @tokens in prompt uses the last token as query
Given a mock command router and persona state for issue 4741
When I instantiate the TUI app for issue 4741
And I call on_mount on the app for issue 4741
And I submit "compare @plan1 with @plan2" to the app for issue 4741 and capture the suggestions query
Then the suggestions query should be "plan2" not "compare plan1 with plan2"
@tdd_issue @tdd_issue_4741
Scenario: Standalone @token at start of prompt uses only the token text as query
Given a mock command router and persona state for issue 4741
When I instantiate the TUI app for issue 4741
And I call on_mount on the app for issue 4741
Outdated
Review

Suggestion: The last scenario is named "Standalone @token at start of prompt" but the input is "@actor:local/dev". The "expected" and "wrong" values are both "actor:local/dev", which makes the assertion redundant (always passes). Consider renaming the scenario and adjusting the test to verify the expected behavior is correct (e.g., expected="actor:local/dev", wrong="@actor:local/dev") to make the test genuinely assert against the buggy behavior.

Suggestion: The last scenario is named "Standalone @token at start of prompt" but the input is `"@actor:local/dev"`. The "expected" and "wrong" values are both `"actor:local/dev"`, which makes the assertion redundant (always passes). Consider renaming the scenario and adjusting the test to verify the expected behavior is correct (e.g., expected="actor:local/dev", wrong="@actor:local/dev") to make the test genuinely assert against the buggy behavior.
And I submit "@actor:local/dev" to the app for issue 4741 and capture the suggestions query
Then the suggestions query should be "actor:local/dev" not "@actor:local/dev"
@tdd_issue @tdd_issue_4741
Scenario: Prompt with no @token does not call suggestions
Given a mock command router and persona state for issue 4741
When I instantiate the TUI app for issue 4741
And I call on_mount on the app for issue 4741
And I submit "analyse the codebase" to the app for issue 4741 and capture the suggestions query
Then suggestions was not called for issue 4741
+5 -3
View File
@@ -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
@@ -475,9 +476,10 @@ 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())
)
at_tokens = re.findall(r"@(\S+)", text)
if at_tokens:
query = at_tokens[-1]
ref_picker.set_suggestions(query, suggestions(query))
if self._facade is None:
# Facade not wired yet — preview only (graceful degradation)