Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions dev-notes/resource-diagnostics-command.md
Original file line number Diff line number Diff line change
@@ -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`.
51 changes: 38 additions & 13 deletions src/tau_coding/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion src/tau_coding/prompt_templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
40 changes: 40 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 53 additions & 1 deletion tests/test_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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")

Expand Down
2 changes: 1 addition & 1 deletion tests/test_prompt_templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
83 changes: 82 additions & 1 deletion tests/test_tui_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -54,6 +54,7 @@
ScopedModelConfig,
save_provider_settings,
)
from tau_coding.resources import ResourceDiagnostic
from tau_coding.session import (
ModelChoice,
SessionTreeBranchResult,
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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())
Expand Down
1 change: 1 addition & 0 deletions website/content/guides/tui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions website/content/reference/slash-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down