fix(cli): fix build-context Rich formatter to use real nested ContextResult shape

_display_build_context was reading top-level item.get("title")/item.get("type")
on each results[i], but the real GraphContext.model_dump() shape wraps every item
as a ContextResult with primary_result + related_results nested inside.  This
made every related note render as an empty tree node.

Fix:
- Rewrite _display_build_context to iterate context_items, build a primary-result
  node from item["primary_result"], then add each item["related_results"] entry as
  a child with relation_type/type/title rendered.
- Update _display_search_results to use the real SearchResponse key "current_page"
  (not "page"), pass query from the CLI argument, add Score and Snippet columns
  (score + matched_chunk/content truncated to 200 chars) as the issue requested.
- Update test fixtures in test_cli_tool_rich_output.py to match the real payload
  shapes (nested ContextResult, current_page, score/matched_chunk fields, no
  updated_at in recent-activity).
- Fix test_build_context_json_flag_overrides_tty assertion to navigate the nested
  primary_result/related_results shape.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Drew Cain <groksrc@gmail.com>
This commit is contained in:
Drew Cain
2026-06-11 01:23:47 -05:00
parent cc49468d0c
commit c6cdf147c2
2 changed files with 109 additions and 37 deletions
+66 -24
View File
@@ -67,15 +67,23 @@ def _print_json(result: Any) -> None:
# --- Rich formatters ---
def _display_search_results(result: dict[str, Any]) -> None:
"""Render search-notes results as a Rich table."""
def _display_search_results(result: dict[str, Any], query: str = "") -> None:
"""Render search-notes results as a Rich table.
Real SearchResponse.model_dump() shape:
results: list of SearchResult dicts (title, type, permalink, score, matched_chunk, content)
current_page: int (NOT "page")
page_size: int
total: int
has_more: bool
"""
results = result.get("results", [])
total = result.get("total", len(results))
query = result.get("query") or ""
page = result.get("page", 1)
page_size = result.get("page_size", len(results))
# Real key is "current_page"; fall back to "page" for forward-compat.
page = result.get("current_page") or result.get("page", 1)
page_size = result.get("page_size", len(results)) or 1
title = f"Search results for [bold cyan]{query}[/bold cyan]" if query else "Search results"
title = f"Search: [bold cyan]{query}[/bold cyan]" if query else "Search results"
subtitle = f"{total} result(s) • page {page} of {max(1, -(-total // page_size))}"
if not results:
@@ -85,13 +93,21 @@ def _display_search_results(result: dict[str, Any]) -> None:
table = Table(show_header=True, header_style="bold", expand=False)
table.add_column("Type", style="dim", width=12)
table.add_column("Title", style="bold cyan")
table.add_column("Score", style="yellow", width=7)
table.add_column("Permalink", style="green")
table.add_column("Snippet", style="dim", max_width=60)
for item in results:
item_type = item.get("type", "")
item_title = item.get("title") or item.get("permalink", "")
permalink = item.get("permalink", "")
table.add_row(item_type, item_title, permalink)
score = item.get("score")
score_str = f"{score:.2f}" if score is not None else ""
# Prefer matched_chunk as the most relevant snippet; fall back to content.
raw_snippet = item.get("matched_chunk") or item.get("content") or ""
# Truncate to ~200 chars so the table stays readable.
snippet = raw_snippet[:200].replace("\n", " ") if raw_snippet else ""
table.add_row(item_type, item_title, score_str, permalink, snippet)
console.print(Panel(table, title=title, subtitle=subtitle, expand=False))
@@ -116,33 +132,59 @@ def _display_read_note(result: dict[str, Any]) -> None:
def _display_build_context(result: dict[str, Any]) -> None:
"""Render build-context result as a Rich tree."""
"""Render build-context result as a Rich tree.
Real GraphContext.model_dump() shape:
results: list of ContextResult dicts, each with:
primary_result: EntitySummary | RelationSummary | ObservationSummary
observations: list of ObservationSummary
related_results: list of EntitySummary | RelationSummary | ObservationSummary
metadata: {"uri": ..., ...}
page/page_size/has_more
Each summary has: type, title (EntitySummary/RelationSummary), permalink,
and relation_type (RelationSummary only).
"""
metadata = result.get("metadata", {})
uri = metadata.get("uri", "")
results = result.get("results", [])
total = len(results)
context_items: list[dict[str, Any]] = list(result.get("results", []))
label = f"[bold cyan]{uri}[/bold cyan]" if uri else "Context"
tree = Tree(f"[bold]Context:[/bold] {label}")
if not results:
if not context_items:
tree.add("[dim]No related content found.[/dim]")
else:
for item in results:
item_title = item.get("title") or item.get("permalink", "")
relation = item.get("relation_type", "")
item_type = item.get("type", "")
for context_result in context_items:
# --- Primary result node ---
primary = context_result.get("primary_result", {})
p_title = primary.get("title") or primary.get("permalink", "")
p_type = primary.get("type", "")
primary_label = f"[cyan]{p_title}[/cyan]"
if p_type:
primary_label = f"[dim]{p_type}[/dim] {primary_label}"
primary_node = tree.add(primary_label)
parts = []
if relation:
parts.append(f"[yellow]{relation}[/yellow]")
if item_type:
parts.append(f"[dim]{item_type}[/dim]")
parts.append(f"[cyan]{item_title}[/cyan]")
# --- Related items as children ---
related: list[dict[str, Any]] = list(context_result.get("related_results", []))
for rel_item in related:
rel_title = rel_item.get("title") or rel_item.get("permalink", "")
rel_type = rel_item.get("type", "")
relation = rel_item.get("relation_type", "")
tree.add(" ".join(parts))
parts = []
if relation:
parts.append(f"[yellow]{relation}[/yellow]")
if rel_type:
parts.append(f"[dim]{rel_type}[/dim]")
parts.append(f"[cyan]{rel_title}[/cyan]")
primary_node.add(" ".join(parts))
subtitle = f"{total} related item(s)"
# Count total related items across all primary results.
total_related = sum(
len(cr.get("related_results", [])) for cr in context_items
)
subtitle = f"{len(context_items)} primary • {total_related} related"
console.print(Panel(tree, subtitle=subtitle, expand=False))
@@ -845,7 +887,7 @@ def search_notes(
if json_output or not _use_rich():
_print_json(result)
else:
_display_search_results(result)
_display_search_results(result, query=query or "")
except ValueError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
+43 -13
View File
@@ -27,46 +27,72 @@ READ_NOTE_RESULT = {
}
SEARCH_RESULT = {
"query": "test",
# Real SearchResponse.model_dump() uses "current_page", not "page".
# No "query" key in the response -- the query comes from the CLI argument.
"total": 2,
"page": 1,
"current_page": 1,
"page_size": 10,
"has_more": False,
"results": [
{
"type": "entity",
"title": "Test Note",
"permalink": "notes/test-note",
"file_path": "notes/Test Note.md",
"score": 0.95,
"matched_chunk": "A snippet about test notes",
"content": None,
},
{
"type": "observation",
"title": "Another Note",
"permalink": "notes/another-note",
"file_path": "notes/Another Note.md",
"score": 0.72,
"matched_chunk": None,
"content": "Full content here",
},
],
}
SEARCH_RESULT_EMPTY = {
"query": "nothing",
"total": 0,
"page": 1,
"current_page": 1,
"page_size": 10,
"has_more": False,
"results": [],
}
BUILD_CONTEXT_RESULT = {
# Real GraphContext.model_dump() shape: results is a list of ContextResult dicts.
# Each ContextResult has primary_result + observations + related_results.
"results": [
{
"type": "entity",
"title": "Related Note",
"permalink": "notes/related",
"relation_type": "references",
"primary_result": {
"type": "entity",
"external_id": "abc123",
"title": "Test Note",
"permalink": "notes/test-note",
"file_path": "notes/Test Note.md",
"created_at": "2025-01-01T00:00:00",
},
"observations": [],
"related_results": [
{
"type": "relation",
"title": "Related Note",
"permalink": "notes/related",
"file_path": "notes/Related Note.md",
"relation_type": "references",
"created_at": "2025-01-01T00:00:00",
}
],
}
],
"metadata": {"uri": "notes/test-note", "depth": 1},
"page": 1,
"page_size": 10,
"has_more": False,
}
BUILD_CONTEXT_EMPTY = {
@@ -74,16 +100,18 @@ BUILD_CONTEXT_EMPTY = {
"metadata": {"uri": "notes/test-note", "depth": 1},
"page": 1,
"page_size": 10,
"has_more": False,
}
RECENT_ACTIVITY_RESULT = [
# Real _extract_recent_rows output keys: type/title/permalink/file_path/created_at
# (optional: project). No "updated_at" key in the real output.
{
"type": "entity",
"title": "Note A",
"permalink": "notes/note-a",
"file_path": "notes/Note A.md",
"created_at": "2025-01-01 00:00:00",
"updated_at": "2025-01-01 12:00:00",
},
{
"type": "entity",
@@ -91,7 +119,6 @@ RECENT_ACTIVITY_RESULT = [
"permalink": "notes/note-b",
"file_path": "notes/Note B.md",
"created_at": "2025-01-02 00:00:00",
"updated_at": None,
},
]
@@ -125,10 +152,11 @@ def test_search_notes_rich_output_default(mock_mcp):
# Rich output should NOT be valid JSON
with pytest.raises((json.JSONDecodeError, ValueError)):
json.loads(result.output)
# But it should contain the result titles
# But it should contain the result titles and partial permalink
assert "Test Note" in result.output
assert "Another Note" in result.output
assert "notes/test-note" in result.output
# Rich may truncate long permalinks with ellipsis; check the prefix.
assert "notes/test-no" in result.output
@patch(
@@ -287,7 +315,9 @@ def test_build_context_json_flag_overrides_tty(mock_mcp):
assert result.exit_code == 0, f"CLI failed: {result.output}"
data = json.loads(result.output)
assert "results" in data
assert data["results"][0]["title"] == "Related Note"
# Real shape: results[i] is a ContextResult with primary_result nested inside.
assert data["results"][0]["primary_result"]["title"] == "Test Note"
assert data["results"][0]["related_results"][0]["title"] == "Related Note"
@patch(