diff --git a/dev-notes/resource-diagnostics-command.md b/dev-notes/resource-diagnostics-command.md new file mode 100644 index 000000000..a778ca838 --- /dev/null +++ b/dev-notes/resource-diagnostics-command.md @@ -0,0 +1,29 @@ +# Resource diagnostics command + +Issue #459 exposes Tau's existing non-fatal resource diagnostics through a +built-in `/diagnostics` command. The command reads +`CodingSession.resource_diagnostics` when invoked, so its output reflects both +initial resource discovery and the latest `/reload`. + +The shared command handler renders an explicit empty state or every diagnostic's +severity, resource kind, optional name, optional source path, and message. Print +mode writes the same text directly. The Textual frontend uses its existing +scrollable command-output modal, which already renders command messages with +markup disabled, so paths and error text containing Rich/Textual markup +characters remain literal. + +`diagnostics.md` is a reserved prompt-template name. Ignoring a colliding +template prevents prompt expansion from shadowing the built-in command and +records a diagnostic explaining the collision. + +Validate manually: + +1. Add an invalid skill, prompt, context file, or extension. +2. Start Tau and run `/diagnostics`. +3. Confirm the modal shows the resource kind, source path, severity, and + remediation message. +4. Fix the resource, run `/reload`, and run `/diagnostics` again. +5. Confirm the modal shows `No resource diagnostics.` + +Automated coverage lives in `tests/test_commands.py`, `tests/test_cli.py`, +`tests/test_prompt_templates.py`, and `tests/test_tui_app.py`. diff --git a/src/tau_coding/commands.py b/src/tau_coding/commands.py index e297c837b..4f8622fd9 100644 --- a/src/tau_coding/commands.py +++ b/src/tau_coding/commands.py @@ -250,6 +250,15 @@ def create_default_command_registry() -> CommandRegistry: search_terms=("info",), ) ) + registry.register( + SlashCommand( + name="diagnostics", + usage="/diagnostics", + description="Show resource loading diagnostics.", + handler=_diagnostics_command, + search_terms=("errors", "warnings", "loading"), + ) + ) registry.register( SlashCommand( name="system", @@ -486,19 +495,13 @@ def _skills_command(context: CommandContext) -> CommandResult: return CommandResult(handled=True, skills_picker_requested=True) -def _resources_command(context: CommandContext) -> CommandResult: - session = context.session - lines = [ - f"Skills: {len(session.skills)}", - f"Prompt templates: {len(session.prompt_templates)}", - f"Context files: {len(session.context_files)}", - ] - if session.resource_diagnostics: - lines.append("") - lines.extend(_format_diagnostics(session.resource_diagnostics)) - else: - lines.append("Resource diagnostics: none") - return CommandResult(handled=True, message="\n".join(lines)) +def _diagnostics_command(context: CommandContext) -> CommandResult: + if context.args: + return CommandResult(handled=True, message="Usage: /diagnostics") + return CommandResult( + handled=True, + message=format_resource_diagnostics(context.session.resource_diagnostics), + ) def _reload_command(context: CommandContext) -> CommandResult: @@ -780,6 +783,28 @@ def _format_diagnostics( return lines +def format_resource_diagnostics(diagnostics: Sequence[ResourceDiagnostic]) -> str: + """Render complete resource diagnostics for command output.""" + if not diagnostics: + return "No resource diagnostics." + + lines = [f"Resource diagnostics ({len(diagnostics)}):"] + for index, diagnostic in enumerate(diagnostics, start=1): + lines.extend( + [ + "", + f"{index}. {diagnostic.severity.upper()}", + f"Kind: {diagnostic.kind}", + ] + ) + if diagnostic.name is not None: + lines.append(f"Name: {diagnostic.name}") + if diagnostic.path is not None: + lines.append(f"Path: {diagnostic.path}") + lines.append(f"Message: {diagnostic.message}") + return "\n".join(lines) + + def _refresh_provider_settings(session: CommandSession) -> CommandResult | None: try: session.reload_provider_settings() diff --git a/src/tau_coding/prompt_templates.py b/src/tau_coding/prompt_templates.py index 24fdb05d0..5af470006 100644 --- a/src/tau_coding/prompt_templates.py +++ b/src/tau_coding/prompt_templates.py @@ -17,7 +17,7 @@ _TEMPLATE_VARIABLE_RE = re.compile(r"{{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*}}") _ARGUMENT_TEMPLATE_VARIABLES = {"arguments", "args"} -_RESERVED_TEMPLATE_NAMES = frozenset({"prompts", "skills", "tools"}) +_RESERVED_TEMPLATE_NAMES = frozenset({"diagnostics", "prompts", "skills", "tools"}) @dataclass(frozen=True, slots=True) diff --git a/tests/test_cli.py b/tests/test_cli.py index 83c69edd8..caf9e6eb6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -450,6 +450,46 @@ async def test_run_print_mode_system_command_prints_prompt_without_provider_call assert await storage.read_all() == [] +@pytest.mark.anyio +async def test_run_print_mode_diagnostics_command_prints_resource_details( + capsys: pytest.CaptureFixture[str], tmp_path: Path +) -> None: + resource_root = tmp_path / "resources" + prompts_dir = resource_root / "prompts" + prompts_dir.mkdir(parents=True) + diagnostic_path = prompts_dir / "diagnostics.md" + diagnostic_path.write_text("Shadow the built-in command.", encoding="utf-8") + provider = FakeProvider([]) + + ok = await run_print_mode( + prompt="/diagnostics", + model="fake", + cwd=tmp_path, + provider=provider, + resource_paths=TauResourcePaths(root=resource_root, agents_root=None), + ) + + captured = capsys.readouterr() + assert ok is True + assert captured.out == "\n".join( + [ + "Resource diagnostics (1):", + "", + "1. WARNING", + "Kind: prompt", + "Name: diagnostics", + f"Path: {diagnostic_path}", + ( + "Message: prompt template name is reserved by the built-in " + "/diagnostics command; template ignored" + ), + "", + ] + ) + assert captured.err == "" + assert provider.calls == [] + + @pytest.mark.anyio async def test_run_print_mode_fails_on_non_recoverable_error( capsys: pytest.CaptureFixture[str], tmp_path: Path diff --git a/tests/test_commands.py b/tests/test_commands.py index 62eb64b58..9bf528e34 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -3,6 +3,7 @@ from tau_coding.commands import CommandRegistry, SlashCommand, create_default_command_registry from tau_coding.paths import TauPaths from tau_coding.reload import CodingReloadSummary, ReloadCategorySummary +from tau_coding.resources import ResourceDiagnostic from tau_coding.session import ModelChoice from tau_coding.session_manager import SessionManager from tau_coding.skills import Skill @@ -114,11 +115,12 @@ def test_registry_ignores_unregistered_slash_prompts(tmp_path: Path) -> None: assert result.message is None -def test_registered_commands_are_pi_aligned(tmp_path: Path) -> None: +def test_registered_commands_are_expected(tmp_path: Path) -> None: commands = create_default_command_registry().list_commands() assert [command.name for command in commands] == [ "compact", + "diagnostics", "export", "hotkeys", "login", @@ -270,6 +272,56 @@ def test_session_command_explains_unavailable_thinking_controls(tmp_path: Path) assert "Thinking mode: medium" not in result.message +def test_diagnostics_command_shows_empty_state(tmp_path: Path) -> None: + result = create_default_command_registry().execute(FakeSession(tmp_path), "/diagnostics") + + assert result.handled is True + assert result.message == "No resource diagnostics." + assert ( + create_default_command_registry() + .execute(FakeSession(tmp_path), "/diagnostics extra") + .message + == "Usage: /diagnostics" + ) + + +def test_diagnostics_command_shows_complete_literal_details(tmp_path: Path) -> None: + session = FakeSession(tmp_path) + prompt_path = tmp_path / "[project]" / "prompts" / "review.md" + session.resource_diagnostics = ( + ResourceDiagnostic( + kind="prompt", + name="review", + path=prompt_path, + severity="warning", + message="template [ignored] because it is invalid", + ), + ResourceDiagnostic( + kind="extension", + severity="error", + message="setup failed", + ), + ) + + result = create_default_command_registry().execute(session, "/diagnostics") + + assert result.message == "\n".join( + [ + "Resource diagnostics (2):", + "", + "1. WARNING", + "Kind: prompt", + "Name: review", + f"Path: {prompt_path}", + "Message: template [ignored] because it is invalid", + "", + "2. ERROR", + "Kind: extension", + "Message: setup failed", + ] + ) + + def test_hotkeys_command_lists_common_tui_shortcuts(tmp_path: Path) -> None: result = create_default_command_registry().execute(FakeSession(tmp_path), "/hotkeys") diff --git a/tests/test_prompt_templates.py b/tests/test_prompt_templates.py index 8886e2169..6440d8a4f 100644 --- a/tests/test_prompt_templates.py +++ b/tests/test_prompt_templates.py @@ -90,7 +90,7 @@ def test_load_prompt_templates_with_diagnostics_reports_overrides(tmp_path: Path assert "overrides lower-precedence resource" in diagnostics[0].message -@pytest.mark.parametrize("reserved_name", ["prompts", "skills", "tools"]) +@pytest.mark.parametrize("reserved_name", ["diagnostics", "prompts", "skills", "tools"]) def test_reserved_picker_template_is_ignored_with_diagnostic( tmp_path: Path, reserved_name: str ) -> None: diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index 31e439675..7b9c5abf9 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -42,7 +42,7 @@ from tau_agent.messages import assistant_content from tau_agent.provider_events import TextDeltaEvent, ThinkingDeltaEvent from tau_coding.catalog_loader import user_catalog_path -from tau_coding.commands import CommandResult +from tau_coding.commands import CommandResult, format_resource_diagnostics from tau_coding.credentials import FileCredentialStore, OAuthCredential from tau_coding.events import AgentSettledEvent, CodingSessionEvent, QueueUpdateEvent from tau_coding.paths import TauPaths @@ -54,6 +54,7 @@ ScopedModelConfig, save_provider_settings, ) +from tau_coding.resources import ResourceDiagnostic from tau_coding.session import ( ModelChoice, SessionTreeBranchResult, @@ -241,6 +242,11 @@ def handle_command(self, text: str) -> CommandResult: ) if text == "/system": return CommandResult(handled=True, message=self.system_prompt) + if text == "/diagnostics": + return CommandResult( + handled=True, + message=format_resource_diagnostics(self.resource_diagnostics), + ) if text == "/skills": return CommandResult(handled=True, skills_picker_requested=True) if text == "/new": @@ -5226,6 +5232,7 @@ async def test_tui_app_opens_command_palette_from_keybinding() -> None: assert prompt.value == "/" assert app._completion_state.items assert any(item.display == "/session" for item in app._completion_state.items) + assert any(item.display == "/diagnostics" for item in app._completion_state.items) assert app.query_one("#autocomplete").display is True @@ -5321,6 +5328,80 @@ async def test_tui_app_help_uses_modal_instead_of_transcript() -> None: assert app.screen.focused is scroll +@pytest.mark.anyio +async def test_tui_app_diagnostics_modal_shows_empty_state_and_dismisses() -> None: + app = TauTuiApp(FakeSession()) + + async with app.run_test() as pilot: + prompt = app.query_one("#prompt", PromptInput) + prompt.value = "/diagnostics" + await pilot.press("enter") + await pilot.pause() + + assert isinstance(app.screen, CommandOutputScreen) + assert app.screen.title_text == "/diagnostics" + assert app.screen.message == "No resource diagnostics." + assert app.state.items == [] + + await pilot.press("escape") + await pilot.pause() + + assert not isinstance(app.screen, CommandOutputScreen) + assert prompt.value == "" + + +@pytest.mark.anyio +async def test_tui_app_diagnostics_modal_reflects_post_reload_details() -> None: + diagnostic_path = Path("/tmp/[project]/prompts/review.md") + diagnostic = ResourceDiagnostic( + kind="prompt", + name="review", + path=diagnostic_path, + severity="warning", + message="template [ignored]; rename it and reload", + ) + + class ReloadingDiagnosticSession(FakeSession): + def handle_command(self, text: str) -> CommandResult: + if text == "/reload": + self.resource_diagnostics = (diagnostic,) + return CommandResult(handled=True, message="Reloaded resources.") + return super().handle_command(text) + + app = TauTuiApp(ReloadingDiagnosticSession()) + + async with app.run_test() as pilot: + prompt = app.query_one("#prompt", PromptInput) + prompt.value = "/diagnostics" + await pilot.press("enter") + await pilot.pause() + assert isinstance(app.screen, CommandOutputScreen) + assert app.screen.message == "No resource diagnostics." + + await pilot.press("escape") + prompt.value = "/reload" + await pilot.press("enter") + await pilot.pause() + + prompt.value = "/diagnostics" + await pilot.press("enter") + await pilot.pause() + + assert isinstance(app.screen, CommandOutputScreen) + body = app.screen.query_one("#command-output-body", Static) + assert str(body.render()) == "\n".join( + [ + "Resource diagnostics (1):", + "", + "1. WARNING", + "Kind: prompt", + "Name: review", + f"Path: {diagnostic_path}", + "Message: template [ignored]; rename it and reload", + ] + ) + + @pytest.mark.anyio async def test_tui_app_tools_reference_opens_filters_and_cancels() -> None: app = TauTuiApp(FakeSession()) diff --git a/website/content/guides/tui.md b/website/content/guides/tui.md index 19d9ebc87..2e6944ddc 100644 --- a/website/content/guides/tui.md +++ b/website/content/guides/tui.md @@ -55,6 +55,7 @@ In-session commands start with `/`. Open the **command palette** with **Ctrl+K** to search and run them. Common ones: - `/session` — show model, tools, skills, and context usage for the session. Text selected in this modal is copied to the clipboard automatically. +- `/diagnostics` — inspect resource loading warnings and errors, including the resource kind, name, source path, severity, and remediation message - `/model` — pick the active model - `/tools` — search active tools by origin and open their full descriptions - `/compact` — summarize and shrink the context diff --git a/website/content/reference/slash-commands.md b/website/content/reference/slash-commands.md index dc24eccb3..767ec699e 100644 --- a/website/content/reference/slash-commands.md +++ b/website/content/reference/slash-commands.md @@ -11,6 +11,7 @@ command palette with **Ctrl+K**. | `/quit` | Exit the session | | `/new` | Start a new session | | `/session` | Show session info and stats (model, cwd, tools, skills, context) | +| `/diagnostics` | Inspect resource loading warnings and errors, including their source paths | | `/system` | Show the active system prompt without adding it to context or session history | | `/compact [instructions]` | Summarize and compact the active context | | `/export [--format html\|jsonl] [dest]` | Export the current session |