fix(tui): extract @token text correctly in on_input_submitted suggestions query #10911
@@ -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)
|
||||
|
|
||||
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
|
||||
|
HAL9000
commented
Suggestion: The last scenario is named "Standalone @token at start of prompt" but the input is 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
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user
Blocking: Project policy has ZERO tolerance for
# type: ignorecomments. 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:This is the preferred approach for dynamic attribute assignment to mock module objects.