From a18d1b8669dc59c209b4479b19e390861bb16e52 Mon Sep 17 00:00:00 2001 From: Aniket Kulkarni Date: Fri, 31 Jul 2026 09:53:00 -0400 Subject: [PATCH 1/8] Improve chat tool rendering and history details --- src/drs/chat_render.py | 173 +++++++++++++++++++++---- src/drs/commands/chat.py | 213 +++++++++++++++++++++++++++---- tests/test_chat_render.py | 67 ++++++++++ tests/test_commands/test_chat.py | 111 +++++++++++++++- 4 files changed, 518 insertions(+), 46 deletions(-) create mode 100644 tests/test_chat_render.py diff --git a/src/drs/chat_render.py b/src/drs/chat_render.py index 0746211..bc584fc 100644 --- a/src/drs/chat_render.py +++ b/src/drs/chat_render.py @@ -20,6 +20,7 @@ import json import sys import threading +from datetime import datetime from typing import Any from rich.console import Console @@ -33,6 +34,77 @@ _SPINNER_INTERVAL = 0.08 +def extract_model_text(name: str, result: dict[str, Any]) -> str: + """Extract the most useful user-facing text from a model result payload.""" + title = result.get("title") + summary = result.get("summary") + if isinstance(title, str) and title.strip() and isinstance(summary, str) and summary.strip(): + return f"{title}\n\n{summary}" + + for key in ("text", "response", "answer", "explanation", "sql_query", "plan", "title"): + value = result.get(key) + if isinstance(value, str) and value.strip(): + return value + + if name == "modelRequestToolApproval": + tool_requests = result.get("toolRequests") + if isinstance(tool_requests, list) and tool_requests: + titles: list[str] = [] + for request in tool_requests: + if not isinstance(request, dict): + continue + title = request.get("summarizedTitle") or request.get("name") + if isinstance(title, str) and title.strip(): + titles.append(title.strip()) + if titles: + return "Tool approval required:\n" + "\n".join(f"- {title}" for title in titles) + return "Tool approval required." + + if result: + return json.dumps(result, indent=2, default=str) + return "" + + +def _format_tool_result(result: Any, max_len: int | None = 500) -> str: + """Return a readable tool result string with optional truncation.""" + if isinstance(result, dict): + text = json.dumps(result, indent=2, default=str) + elif isinstance(result, str): + text = result + else: + text = str(result) + + if max_len is not None and len(text) > max_len: + return text[:max_len] + "\n..." + return text + + +def _parse_timestamp(value: str | None) -> datetime | None: + """Parse ISO-8601 timestamps emitted by the chat API.""" + if not value: + return None + normalized = value.replace("Z", "+00:00") + try: + return datetime.fromisoformat(normalized) + except ValueError: + return None + + +def _format_timestamp(value: str | None) -> str: + ts = _parse_timestamp(value) + if ts is None: + return "" + return ts.strftime("%H:%M:%S") + + +def _format_duration_ms(duration_ms: float | None) -> str: + if duration_ms is None: + return "" + if duration_ms < 1000: + return f"{int(duration_ms)} ms" + return f"{duration_ms / 1000:.2f}s" + + class _Spinner: """A lightweight terminal spinner that does NOT use Rich's Live display. @@ -77,15 +149,16 @@ def _run(self) -> None: class ChatRenderer: """Renders agent SSE events to a Rich console (interactive mode).""" - def __init__(self, console: Console | None = None) -> None: + def __init__(self, console: Console | None = None, show_tool_details: bool = False) -> None: self.console = console or Console() self._spinner: _Spinner | None = None + self._show_tool_details = show_tool_details # -- Model output -- def render_model_chunk(self, name: str, result: dict) -> None: """Render a model output chunk based on the task type.""" - text = result.get("text", "") + text = extract_model_text(name, result) if not text: return @@ -105,6 +178,7 @@ def render_tool_request( name: str, arguments: dict | None = None, title: str | None = None, + created_at: str | None = None, ) -> None: """Show a tool call request in a bordered panel.""" display_name = title or name @@ -112,21 +186,39 @@ def render_tool_request( if arguments: args_summary = _summarize_args(arguments) - body = Text(args_summary, style="dim") if args_summary else Text("(no arguments)", style="dim") + body_lines: list[str] = [] + if self._show_tool_details: + formatted_time = _format_timestamp(created_at) + if formatted_time: + body_lines.append(f"Started: {formatted_time}") + body_lines.append(args_summary or "(no arguments)") + body = Text("\n".join(body_lines), style="dim") self.console.print( Panel(body, title=f"[bold cyan]Tool: {display_name}[/]", border_style="cyan", expand=False), ) - def render_tool_response(self, call_id: str, name: str, result: Any) -> None: + def render_tool_response( + self, + call_id: str, + name: str, + result: Any, + created_at: str | None = None, + duration_ms: float | None = None, + ) -> None: """Show a tool result in a muted panel.""" - if isinstance(result, dict): - text = json.dumps(result, indent=2, default=str) - if len(text) > 500: - text = text[:500] + "\n..." - elif isinstance(result, str): - text = result[:500] + ("..." if len(result) > 500 else "") + if self._show_tool_details: + meta: list[str] = [] + formatted_time = _format_timestamp(created_at) + formatted_duration = _format_duration_ms(duration_ms) + if formatted_time: + meta.append(f"Finished: {formatted_time}") + if formatted_duration: + meta.append(f"Duration: {formatted_duration}") + text = _format_tool_result(result, max_len=None) + if meta: + text = "\n".join(meta) + "\n\n" + text else: - text = str(result)[:500] + text = _format_tool_result(result) self.console.print( Panel(Text(text, style="dim"), title=f"[dim]{name} result[/]", border_style="dim", expand=False), @@ -172,7 +264,7 @@ def prompt_tool_approval(self, nonce: str, tools: list[dict]) -> dict: decisions: list[dict] = [] for tool in tools: tool_name = tool.get("name", "unknown") - tool_id = tool.get("callId", tool.get("id", "")) + tool_id = tool.get("executionId", tool.get("callId", tool.get("id", ""))) args = tool.get("arguments", {}) self.render_tool_request(tool_id, tool_name, args) try: @@ -182,8 +274,10 @@ def prompt_tool_approval(self, nonce: str, tools: list[dict]) -> dict: approved = answer in ("", "y", "yes") decisions.append( { - "callId": tool_id, - "decision": "approved" if approved else "denied", + "executionId": tool_id, + "name": tool_name, + "arguments": args if isinstance(args, dict) else {}, + "approved": approved, } ) return { @@ -235,14 +329,15 @@ class PlainRenderer: Tool events and progress always go to stderr. """ - def __init__(self) -> None: + def __init__(self, show_tool_details: bool = False) -> None: self._is_tty = sys.stdout.isatty() self._console = Console() if self._is_tty else None self._stderr_console = Console(stderr=True, highlight=False) self._spinner: _Spinner | None = None + self._show_tool_details = show_tool_details def render_model_chunk(self, name: str, result: dict) -> None: - text = result.get("text", "") + text = extract_model_text(name, result) if not text: return if self._console is not None: @@ -262,15 +357,47 @@ def render_tool_request( name: str, arguments: dict | None = None, title: str | None = None, + created_at: str | None = None, ) -> None: - self._stderr_console.print( - Text(f" ⚙ {title or name}", style="dim cyan"), - ) + if self._show_tool_details: + display_name = title or name + header = f" ⚙ {display_name}" + formatted_time = _format_timestamp(created_at) + if formatted_time: + header += f" [{formatted_time}]" + args_summary = _summarize_args(arguments) if arguments else "(no arguments)" + self._stderr_console.print( + Panel(Text(args_summary, style="dim"), title=header, border_style="cyan", expand=False), + ) + return + self._stderr_console.print(Text(f" ⚙ {title or name}", style="dim cyan")) - def render_tool_response(self, call_id: str, name: str, result: Any) -> None: - self._stderr_console.print( - Text(f" ✓ {name} done", style="dim"), - ) + def render_tool_response( + self, + call_id: str, + name: str, + result: Any, + created_at: str | None = None, + duration_ms: float | None = None, + ) -> None: + if self._show_tool_details: + header = f" ✓ {name}" + formatted_duration = _format_duration_ms(duration_ms) + if formatted_duration: + header += f" ({formatted_duration})" + formatted_time = _format_timestamp(created_at) + if formatted_time: + header += f" [{formatted_time}]" + self._stderr_console.print( + Panel( + Text(_format_tool_result(result, max_len=None), style="dim"), + title=header, + border_style="dim", + expand=False, + ), + ) + return + self._stderr_console.print(Text(f" ✓ {name} done", style="dim")) def render_tool_progress(self, status: str, message: str) -> None: self._stderr_console.print( diff --git a/src/drs/commands/chat.py b/src/drs/commands/chat.py index d80bf21..741dc00 100644 --- a/src/drs/commands/chat.py +++ b/src/drs/commands/chat.py @@ -22,6 +22,7 @@ import json import logging import sys +from datetime import datetime from enum import StrEnum from pathlib import Path from typing import Any @@ -38,7 +39,15 @@ from rich.text import Text from drs.chat_gantt import load_history_dump, render_tool_gantt -from drs.chat_render import ChatRenderer, PlainRenderer +from drs.chat_render import ( + ChatRenderer, + PlainRenderer, + _format_duration_ms, + _format_timestamp, + _format_tool_result, + _summarize_args, + extract_model_text, +) from drs.client import DremioClient from drs.output import error as print_error from drs.sse import parse_sse_stream @@ -106,8 +115,10 @@ def _render_conversations_table(console: Console, rows: list[dict]) -> None: console.print(table) -def _render_history_table(console: Console, rows: list[dict]) -> None: +def _render_history_table(console: Console, rows: list[dict], show_tool_details: bool = False) -> None: """Render conversation history as a readable transcript.""" + tool_started_at: dict[str, datetime] = {} + for row in rows: chunk_type = row.get("chunkType", "") timestamp = str(row.get("createdAt", "")) @@ -121,7 +132,7 @@ def _render_history_table(console: Console, rows: list[dict]) -> None: elif chunk_type == "model": result = row.get("result", {}) - text = result.get("text", "") if isinstance(result, dict) else str(result) + text = extract_model_text(row.get("name", ""), result) if isinstance(result, dict) else str(result) name = row.get("name", "") title = "[bold blue]Agent[/]" if name and name != "modelGeneric": @@ -132,11 +143,60 @@ def _render_history_table(console: Console, rows: list[dict]) -> None: elif chunk_type == "toolRequest": tool_name = row.get("name", "") summarized = row.get("summarizedTitle", tool_name) - console.print(Text(f" ⚙ {summarized}", style="dim cyan")) + call_id = row.get("callId", "") + started_at = _parse_event_timestamp(row.get("createdAt")) + if call_id and started_at is not None: + tool_started_at[call_id] = started_at + if show_tool_details: + formatted_time = _format_timestamp(row.get("createdAt")) + args_summary = "" + arguments = row.get("arguments") + if isinstance(arguments, dict): + args_summary = _summarize_args(arguments) + details = [] + if formatted_time: + details.append(f"Started: {formatted_time}") + details.append(args_summary or "(no arguments)") + console.print( + Panel( + Text("\n".join(details), style="dim"), + title=f"[bold cyan]Tool: {summarized}[/]", + border_style="cyan", + expand=False, + ) + ) + else: + console.print(Text(f" ⚙ {summarized}", style="dim cyan")) elif chunk_type == "toolResponse": tool_name = row.get("name", "") - console.print(Text(f" ✓ {tool_name} done", style="dim")) + if show_tool_details: + call_id = row.get("callId", "") + finished_at = _parse_event_timestamp(row.get("createdAt")) + started_at = tool_started_at.pop(call_id, None) + duration_ms = None + if started_at is not None and finished_at is not None: + duration_ms = max((finished_at - started_at).total_seconds() * 1000, 0.0) + meta: list[str] = [] + formatted_time = _format_timestamp(row.get("createdAt")) + formatted_duration = _format_duration_ms(duration_ms) + if formatted_time: + meta.append(f"Finished: {formatted_time}") + if formatted_duration: + meta.append(f"Duration: {formatted_duration}") + body = _format_tool_result(row.get("result"), max_len=None) + if meta: + body = "\n".join(meta) + "\n\n" + body + console.print( + Panel( + Text(body, style="dim"), + title=f"[dim]{tool_name} result[/]", + border_style="dim", + expand=False, + ) + ) + else: + console.print(Text(f" ✓ {tool_name} done", style="dim")) def _render_generic_table(console: Console, rows: list[dict]) -> None: @@ -163,7 +223,7 @@ async def create_conversation( """POST /agent/conversations — start a new conversation.""" body: dict[str, Any] = {"prompt": {"text": text}} if model: - body["model"] = model + body["modelName"] = model try: return await client.create_conversation(body) except httpx.HTTPStatusError as exc: @@ -184,7 +244,7 @@ async def send_message( if approvals: body["prompt"]["approvals"] = approvals if model: - body["model"] = model + body["modelName"] = model try: return await client.send_conversation_message(conversation_id, body) except httpx.HTTPStatusError as exc: @@ -256,6 +316,61 @@ def _extract_ids(result: dict) -> tuple[str | None, str | None]: return conv_id, run_id +def _extract_tool_approval(data: dict[str, Any]) -> tuple[str, list[dict[str, Any]]] | None: + """Extract a tool approval request from either legacy or model-based chunks.""" + if data.get("chunkType") == "interrupt": + nonce = data.get("approvalNonce") + tools = data.get("toolDecisions") + if isinstance(nonce, str) and isinstance(tools, list): + return nonce, tools + + if data.get("chunkType") != "model" or data.get("name") != "modelRequestToolApproval": + return None + + result = data.get("result") + if not isinstance(result, dict): + return None + + nonce = result.get("approvalNonce") + tool_requests = result.get("toolRequests") + if not isinstance(nonce, str) or not isinstance(tool_requests, list): + return None + return nonce, tool_requests + + +def _build_approval_payload(nonce: str, tools: list[dict[str, Any]], auto_approve: bool) -> dict[str, Any]: + """Convert streamed approval requests into the v2 approvals payload.""" + decisions: list[dict[str, Any]] = [] + for tool in tools: + execution_id = tool.get("executionId", tool.get("callId", tool.get("id", ""))) + name = tool.get("name", "") + arguments = tool.get("arguments", {}) + if not execution_id or not name: + continue + decisions.append( + { + "executionId": execution_id, + "name": name, + "arguments": arguments if isinstance(arguments, dict) else {}, + "approved": auto_approve, + } + ) + return { + "approvalNonce": nonce, + "toolDecisions": decisions, + } + + +def _parse_event_timestamp(value: Any) -> datetime | None: + """Parse an event timestamp emitted by the chat API.""" + if not isinstance(value, str): + return None + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + + # --------------------------------------------------------------------------- # SSE event dispatch # --------------------------------------------------------------------------- @@ -276,6 +391,7 @@ async def dispatch_events( """ renderer.start_spinner() first_model_chunk = True + tool_started_at: dict[str, datetime] = {} try: async for event in stream_run(client, conversation_id, run_id): @@ -295,22 +411,63 @@ async def dispatch_events( result = data.get("result", {}) renderer.render_model_chunk(name, result) + approval_request = _extract_tool_approval(data) + if approval_request is not None: + nonce, tools = approval_request + if interactive and isinstance(renderer, ChatRenderer): + approvals = renderer.prompt_tool_approval(nonce, tools) + else: + approvals = _build_approval_payload(nonce, tools, auto_approve) + + resp = await send_message( + client, + conversation_id, + approvals=approvals, + ) + _, new_run_id = _extract_ids(resp) + if new_run_id: + run_id = new_run_id + renderer.start_spinner() + first_model_chunk = True + return await dispatch_events( + client, + renderer, + conversation_id, + run_id, + auto_approve=auto_approve, + interactive=interactive, + log_file=log_file, + ) + elif chunk_type == "toolRequest": renderer.stop_spinner() + call_id = data.get("callId", "") + started_at = _parse_event_timestamp(data.get("createdAt")) + if call_id and started_at is not None: + tool_started_at[call_id] = started_at renderer.render_tool_request( - call_id=data.get("callId", ""), + call_id=call_id, name=data.get("name", ""), arguments=data.get("arguments"), title=data.get("summarizedTitle"), + created_at=data.get("createdAt"), ) renderer.start_spinner() elif chunk_type == "toolResponse": renderer.stop_spinner() + call_id = data.get("callId", "") + finished_at = _parse_event_timestamp(data.get("createdAt")) + started_at = tool_started_at.pop(call_id, None) + duration_ms = None + if started_at is not None and finished_at is not None: + duration_ms = max((finished_at - started_at).total_seconds() * 1000, 0.0) renderer.render_tool_response( - call_id=data.get("callId", ""), + call_id=call_id, name=data.get("name", ""), result=data.get("result"), + created_at=data.get("createdAt"), + duration_ms=duration_ms, ) renderer.start_spinner() @@ -329,21 +486,15 @@ async def dispatch_events( elif chunk_type == "interrupt": renderer.stop_spinner() - nonce = data.get("approvalNonce", "") - tools = data.get("toolDecisions", []) + approval_request = _extract_tool_approval(data) + if approval_request is None: + continue + nonce, tools = approval_request if interactive and isinstance(renderer, ChatRenderer): approvals = renderer.prompt_tool_approval(nonce, tools) else: - decisions = [] - for tool in tools: - decisions.append( - { - "callId": tool.get("callId", tool.get("id", "")), - "decision": "approved" if auto_approve else "denied", - } - ) - approvals = {"approvalNonce": nonce, "toolDecisions": decisions} + approvals = _build_approval_payload(nonce, tools, auto_approve) resp = await send_message( client, @@ -541,9 +692,10 @@ async def chat_oneshot( auto_approve: bool = False, model: str | None = None, log_file: Any | None = None, + show_tool_details: bool = False, ) -> None: """Send a single message and stream the response to stdout.""" - renderer = PlainRenderer() + renderer = PlainRenderer(show_tool_details=show_tool_details) if conversation_id is None: result = await create_conversation(client, message, model=model) @@ -593,6 +745,11 @@ def chat_main( auto_approve: bool = typer.Option(False, "--auto-approve", help="Auto-approve tool calls (non-interactive only)"), log_file: str | None = typer.Option(None, "--log-file", help="Path to JSON-lines event log file"), model: str | None = typer.Option(None, "--model", help="Model override"), + show_tool_details: bool = typer.Option( + False, + "--show-tool-details", + help="Show tool timestamps, durations, and detailed tool results", + ), ) -> None: """Chat with the Dremio AI Agent. Launches interactive REPL by default.""" if ctx.invoked_subcommand is not None: @@ -615,9 +772,10 @@ async def _run() -> None: auto_approve=auto_approve, model=model, log_file=log_fh, + show_tool_details=show_tool_details, ) else: - renderer = ChatRenderer() + renderer = ChatRenderer(show_tool_details=show_tool_details) await chat_repl( client, renderer, @@ -674,6 +832,11 @@ def chat_history( ascii: bool = typer.Option( False, "--ascii", help="With --gantt, render plain ASCII output instead of launching the Textual TUI" ), + show_tool_details: bool = typer.Option( + False, + "--show-tool-details", + help="Show tool timestamps, durations, and detailed tool results in transcript output", + ), fmt: ChatFormat = typer.Option(ChatFormat.table, "--format", "-f", help="Output format: json, table"), ) -> None: """Show message history for a conversation.""" @@ -702,6 +865,12 @@ async def _run(): except ValueError as exc: print_error(f"Unable to render Gantt chart: {exc}") raise typer.Exit(1) + if fmt == ChatFormat.table: + console = Console() + rows = result.get("data", result.get("messages", [])) + if rows and isinstance(rows, list) and isinstance(rows[0], dict) and "chunkType" in rows[0]: + _render_history_table(console, rows, show_tool_details=show_tool_details) + return _chat_output(result, fmt) diff --git a/tests/test_chat_render.py b/tests/test_chat_render.py new file mode 100644 index 0000000..9329b0e --- /dev/null +++ b/tests/test_chat_render.py @@ -0,0 +1,67 @@ +# +# Copyright (C) 2017-2026 Dremio Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Tests for chat renderer detail output.""" + +from __future__ import annotations + +from io import StringIO + +from rich.console import Console + +from drs.chat_render import ChatRenderer, PlainRenderer, extract_model_text + + +def test_extract_model_text_prefers_title_and_summary() -> None: + text = extract_model_text( + "modelSqlAnswer", + {"title": "Direct Reports", "summary": "Three people report to Myra."}, + ) + + assert text == "Direct Reports\n\nThree people report to Myra." + + +def test_chat_renderer_tool_response_details_include_duration() -> None: + stream = StringIO() + renderer = ChatRenderer(console=Console(file=stream, force_terminal=False), show_tool_details=True) + + renderer.render_tool_response( + call_id="call-1", + name="runSql", + result={"rows": [["Lesley Ellis"]]}, + created_at="2026-07-31T12:10:35.270Z", + duration_ms=1803, + ) + + output = stream.getvalue() + assert "Finished: 12:10:35" in output + assert "Duration: 1.80s" in output + assert '"rows"' in output + + +def test_plain_renderer_tool_request_details_include_timestamp(capsys) -> None: + renderer = PlainRenderer(show_tool_details=True) + + renderer.render_tool_request( + call_id="call-1", + name="runSql", + arguments={"sqlText": "select 1"}, + title="Run direct reports query", + created_at="2026-07-31T12:10:33.467Z", + ) + + captured = capsys.readouterr() + assert "Run direct reports query [12:10:33]" in captured.err + assert "sqlText=select 1" in captured.err diff --git a/tests/test_commands/test_chat.py b/tests/test_commands/test_chat.py index 2490418..8af9bff 100644 --- a/tests/test_commands/test_chat.py +++ b/tests/test_commands/test_chat.py @@ -17,6 +17,7 @@ from __future__ import annotations +from io import StringIO from unittest.mock import AsyncMock import pytest @@ -32,6 +33,9 @@ ) from drs.chat_gantt_tui import ToolTimeline, _build_span_sections from drs.commands.chat import ( + _build_approval_payload, + _extract_tool_approval, + _render_history_table, cancel_run, create_conversation, delete_conversation, @@ -59,7 +63,7 @@ async def test_create_conversation_with_model(mock_client) -> None: mock_client.create_conversation = AsyncMock(return_value={"id": "conv-1"}) await create_conversation(mock_client, "hello", model="gpt-test") call_args = mock_client.create_conversation.call_args[0][0] - assert call_args["model"] == "gpt-test" + assert call_args["modelName"] == "gpt-test" @pytest.mark.asyncio @@ -89,6 +93,54 @@ async def test_send_message_approval(mock_client) -> None: assert result["runId"] == "run-3" +def test_extract_tool_approval_from_model_chunk() -> None: + event = { + "chunkType": "model", + "name": "modelRequestToolApproval", + "result": { + "approvalNonce": "nonce-1", + "toolRequests": [ + { + "executionId": "exec-1", + "name": "runSql", + "arguments": {"sqlText": "select 1"}, + } + ], + }, + } + + assert _extract_tool_approval(event) == ( + "nonce-1", + event["result"]["toolRequests"], + ) + + +def test_build_approval_payload_uses_v2_shape() -> None: + approvals = _build_approval_payload( + "nonce-1", + [ + { + "executionId": "exec-1", + "name": "runSql", + "arguments": {"sqlText": "select 1"}, + } + ], + auto_approve=True, + ) + + assert approvals == { + "approvalNonce": "nonce-1", + "toolDecisions": [ + { + "executionId": "exec-1", + "name": "runSql", + "arguments": {"sqlText": "select 1"}, + "approved": True, + } + ], + } + + @pytest.mark.asyncio async def test_list_conversations(mock_client) -> None: mock_client.list_conversations = AsyncMock( @@ -209,6 +261,63 @@ def test_build_history_bounds_uses_all_events() -> None: assert bounds.total_ms == 17206 +def test_render_history_table_uses_model_summary_text() -> None: + stream = StringIO() + console = Console(file=stream, force_terminal=False) + + _render_history_table( + console, + [ + { + "chunkType": "model", + "createdAt": "2026-07-31T12:10:37.969Z", + "name": "modelSqlAnswer", + "result": { + "title": "Direct Reports to Myra Richmond", + "summary": "Myra Richmond has three direct reports.", + }, + } + ], + ) + + output = stream.getvalue() + assert "Direct Reports to Myra Richmond" in output + assert "Myra Richmond has three direct reports." in output + + +def test_render_history_table_shows_tool_timing_details() -> None: + stream = StringIO() + console = Console(file=stream, force_terminal=False) + + _render_history_table( + console, + [ + { + "chunkType": "toolRequest", + "callId": "call-1", + "name": "runSql", + "summarizedTitle": "Run report query", + "createdAt": "2026-07-31T12:10:33.467Z", + "arguments": {"sqlText": "select 1"}, + }, + { + "chunkType": "toolResponse", + "callId": "call-1", + "name": "runSql", + "createdAt": "2026-07-31T12:10:35.270Z", + "result": {"rows": [["Lesley Ellis"]]}, + }, + ], + show_tool_details=True, + ) + + output = stream.getvalue() + assert "Started: 12:10:33" in output + assert "Finished: 12:10:35" in output + assert "Duration: 1.80s" in output + assert '"rows"' in output + + def test_build_tool_spans_can_insert_think_time() -> None: rows = [ { From fdae70ebc38affc1ed324943a8309f4aa1477185 Mon Sep 17 00:00:00 2001 From: Aniket Kulkarni Date: Fri, 31 Jul 2026 10:43:53 -0400 Subject: [PATCH 2/8] Adapt chat gantt colors to terminal theme --- src/drs/chat_gantt_tui.py | 97 +++++++++++++++++++++++++++++------- tests/test_chat_gantt_tui.py | 39 +++++++++++++++ 2 files changed, 118 insertions(+), 18 deletions(-) create mode 100644 tests/test_chat_gantt_tui.py diff --git a/src/drs/chat_gantt_tui.py b/src/drs/chat_gantt_tui.py index 9d5f16c..9d96a92 100644 --- a/src/drs/chat_gantt_tui.py +++ b/src/drs/chat_gantt_tui.py @@ -49,6 +49,49 @@ ROW_FIXED_WIDTH = 2 + ROW_LABEL_WIDTH + LABEL_WIDTH + 1 + 12 +@dataclass(frozen=True) +class GanttPalette: + """Theme-aware colors for the Gantt TUI.""" + + axis: str + think_time: str + label: str + muted_label: str + panel_border: str + summary_border: str + selection_border: str + + +def _theme_is_dark(app: App | None) -> bool: + """Return whether the active Textual theme is dark.""" + if app is None: + return True + return app.current_theme.dark + + +def _palette(app: App | None) -> GanttPalette: + """Choose foreground colors that work on transparent light or dark terminals.""" + if _theme_is_dark(app): + return GanttPalette( + axis="grey70", + think_time="grey50", + label="white", + muted_label="grey70", + panel_border="bright_blue", + summary_border="cyan", + selection_border="green", + ) + return GanttPalette( + axis="grey30", + think_time="grey50", + label="black", + muted_label="grey30", + panel_border="blue", + summary_border="dark_cyan", + selection_border="dark_green", + ) + + def _header_chart_width(viewport_width: int) -> int: """Choose a header chart width that follows the viewport size.""" return max(viewport_width - 4, 40) @@ -75,7 +118,7 @@ def _span_marker(span: ToolSpan) -> tuple[str, str]: if span.failed: return "●", "red" if span.name == "thinkTime": - return "•", "bright_black" + return "•", "grey50" return "●", "green" @@ -181,6 +224,7 @@ def __init__(self, timeline: ToolTimeline, **kwargs) -> None: self.timeline = timeline def render(self) -> RenderableType: + palette = _palette(self.app if self.is_mounted else None) width = _header_chart_width(self.size.width) chart_lines = [] axis_ticks = 5 @@ -195,12 +239,12 @@ def render(self) -> RenderableType: for off, ch in enumerate(label): labels[start_idx + off] = ch - chart_lines.append(Text("".join(labels), style="dim")) - chart_lines.append(Text("".join(markers), style="dim")) + chart_lines.append(Text("".join(labels), style=palette.axis)) + chart_lines.append(Text("".join(markers), style=palette.axis)) legend = Text("Legend: ", style="bold") legend.append("● error ", style="red") legend.append("● success ", style="green") - legend.append("■ think time ", style="bright_black") + legend.append("■ think time ", style=palette.think_time) legend.append("■ short (<5%) ", style="green") legend.append("■ medium (5-15%) ", style="yellow") legend.append("■ long (15-30%) ", style="magenta") @@ -215,7 +259,7 @@ def render(self) -> RenderableType: f"{format_duration_ms(self.timeline.history_bounds.total_ms)} total time " "↑/↓ select ←/→ pan Enter details" ) - return Panel(body, title="Tool Timeline", subtitle=subtitle, border_style="blue") + return Panel(body, title="Tool Timeline", subtitle=subtitle, border_style=palette.panel_border) class GanttRows(Static): @@ -228,6 +272,7 @@ def __init__(self, timeline: ToolTimeline, **kwargs) -> None: self.timeline = timeline def render(self) -> RenderableType: + palette = _palette(self.app if self.is_mounted else None) label_width = LABEL_WIDTH width = _row_bar_width(self.size.width) chart_lines = [] @@ -240,7 +285,7 @@ def render(self) -> RenderableType: color = ( "red" if span.failed - else "bright_black" + else palette.think_time if span.name == "thinkTime" else _duration_color(span.duration_ms, self.timeline.total_ms) ) @@ -257,11 +302,13 @@ def render(self) -> RenderableType: bar.append(f"Step {span.step}".ljust(ROW_LABEL_WIDTH), style=f"bold {line_style}".strip()) bar.append(f"{marker} ", style=f"{marker_style} {line_style}".strip()) bar.append( - truncate_label(span.label, label_width - 2).ljust(label_width), style=f"white {line_style}".strip() + truncate_label(span.label, label_width - 2).ljust(label_width), + style=f"{palette.label} {line_style}".strip(), ) bar.append(" ", style=line_style) bar.append("".join(fill), style=bar_style) - bar.append(f" {format_duration_ms(span.duration_ms)}", style=f"dim {line_style}".strip()) + duration_style = "red" if span.failed else palette.muted_label + bar.append(f" {format_duration_ms(span.duration_ms)}", style=f"{duration_style} {line_style}".strip()) chart_lines.append(bar) return Group(*chart_lines) @@ -299,17 +346,19 @@ class ToolDetails(Static): """Details pane for the selected tool call.""" def show_placeholder(self) -> None: + palette = _palette(self.app if self.is_mounted else None) self.update( Panel( - Text("Select a Gantt row with ↑/↓ and press Enter to view details.", style="dim"), + Text("Select a Gantt row with ↑/↓ and press Enter to view details.", style=palette.muted_label), title="Selection", - border_style="green", + border_style=palette.selection_border, ) ) def show_span(self, span: ToolSpan) -> None: sections = _build_span_sections(span, self.app.timeline) - self.update(Panel(Group(*sections), title="Selection", border_style="green")) + palette = _palette(self.app if self.is_mounted else None) + self.update(Panel(Group(*sections), title="Selection", border_style=palette.selection_border)) class ToolDetailModal(ModalScreen[None]): @@ -318,17 +367,19 @@ class ToolDetailModal(ModalScreen[None]): CSS = """ ToolDetailModal { align: center middle; + background: transparent; } #detail-modal { width: 88%; height: 88%; border: round $accent; - background: $surface; + background: transparent; padding: 1 2; } #detail-modal-body { height: 1fr; width: 1fr; + background: transparent; } """ @@ -368,31 +419,39 @@ class ChatGanttApp(App[None]): CSS = """ Screen { layout: vertical; + background: transparent; } #main { height: 1fr; + background: transparent; } #table-pane { width: 48; min-width: 36; + background: transparent; } #spans { height: 1fr; + background: transparent; } #chart-scroll { height: 1fr; width: 1fr; + background: transparent; } #chart-header-scroll { height: 6; width: 1fr; + background: transparent; } #details { height: 12; + background: transparent; } #summary { height: 7; min-height: 7; + background: transparent; } """ @@ -425,6 +484,7 @@ def compose(self) -> ComposeResult: yield Footer() def on_mount(self) -> None: + palette = _palette(self) table = self.query_one("#spans", DataTable) table.cursor_type = "row" table.zebra_stripes = True @@ -435,16 +495,17 @@ def on_mount(self) -> None: offset_cell: str | Text duration_cell: str | Text if span.name == "thinkTime": - tool_cell = Text.assemble(("• ", "bright_black"), ("think time", "dim")) - offset_cell = Text("", style="dim") - duration_cell = Text(format_duration_ms(span.duration_ms), style="dim") + tool_cell = Text.assemble(("• ", palette.think_time), ("think time", palette.muted_label)) + offset_cell = Text("", style=palette.muted_label) + duration_cell = Text(format_duration_ms(span.duration_ms), style=palette.muted_label) else: marker, marker_style = _span_marker(span) tool_cell = Text.assemble( - (f"{marker} ", marker_style), (truncate_label(span.name, 22), "red" if span.failed else "") + (f"{marker} ", marker_style), + (truncate_label(span.name, 22), "red" if span.failed else palette.label), ) offset_cell = format_duration_ms(span.offset_ms) - duration_cell = Text(format_duration_ms(span.duration_ms), style="red" if span.failed else "") + duration_cell = Text(format_duration_ms(span.duration_ms), style="red" if span.failed else palette.label) table.add_row( str(span.step), tool_cell, @@ -470,7 +531,7 @@ def on_mount(self) -> None: ) ), title="Summary", - border_style="cyan", + border_style=palette.summary_border, ) ) table.focus() diff --git a/tests/test_chat_gantt_tui.py b/tests/test_chat_gantt_tui.py new file mode 100644 index 0000000..9885d91 --- /dev/null +++ b/tests/test_chat_gantt_tui.py @@ -0,0 +1,39 @@ +# +# Copyright (C) 2017-2026 Dremio Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Tests for theme-aware chat Gantt TUI colors.""" + +from __future__ import annotations + +from types import SimpleNamespace + +from drs.chat_gantt_tui import _palette + + +def test_palette_defaults_to_dark_mode() -> None: + palette = _palette(None) + + assert palette.label == "white" + assert palette.panel_border == "bright_blue" + + +def test_palette_switches_for_light_theme() -> None: + app = SimpleNamespace(current_theme=SimpleNamespace(dark=False)) + + palette = _palette(app) + + assert palette.label == "black" + assert palette.panel_border == "blue" + assert palette.selection_border == "dark_green" From 713481029236d4230e09acced5f47ac50f6154f2 Mon Sep 17 00:00:00 2001 From: Aniket Kulkarni Date: Tue, 4 Aug 2026 15:24:09 -0400 Subject: [PATCH 3/8] Add chat HTML overview report parity --- src/drs/chat_gantt_html.py | 1199 +++++++++++++++++++++++++++++++++ src/drs/commands/chat.py | 93 +++ tests/test_chat_gantt_html.py | 227 +++++++ tests/test_cli.py | 74 ++ 4 files changed, 1593 insertions(+) create mode 100644 src/drs/chat_gantt_html.py create mode 100644 tests/test_chat_gantt_html.py diff --git a/src/drs/chat_gantt_html.py b/src/drs/chat_gantt_html.py new file mode 100644 index 0000000..3aa8a64 --- /dev/null +++ b/src/drs/chat_gantt_html.py @@ -0,0 +1,1199 @@ +# +# Copyright (C) 2017-2026 Dremio Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Standalone HTML export for chat history Gantt timelines.""" + +from __future__ import annotations + +import json +from pathlib import Path +from statistics import mean, median, pstdev +from typing import Any + +from drs.chat_gantt import ( + build_history_bounds, + build_tool_spans, + extract_history_rows, + format_duration_ms, +) +from drs.chat_render import extract_model_text + + +def _extract_conversation_summary(rows: list[dict[str, Any]]) -> dict[str, str | None]: + """Extract the latest user-visible model title/summary/text from conversation history.""" + title: str | None = None + summary: str | None = None + + for row in rows: + if row.get("chunkType") != "model": + continue + result = row.get("result") + if not isinstance(result, dict): + continue + candidate_title = result.get("title") + if isinstance(candidate_title, str) and candidate_title.strip(): + title = candidate_title.strip() + candidate_summary = result.get("summary") + if isinstance(candidate_summary, str) and candidate_summary.strip(): + summary = candidate_summary.strip() + elif not summary: + extracted = extract_model_text(str(row.get("name", "")), result).strip() + if extracted: + summary = extracted + + return {"title": title, "summary": summary} + + +def _extract_conversation_result(rows: list[dict[str, Any]]) -> str | None: + """Extract the latest user-visible model text for the conversation result.""" + for row in reversed(rows): + if row.get("chunkType") != "model": + continue + result = row.get("result") + if not isinstance(result, dict): + continue + text = extract_model_text(str(row.get("name", "")), result).strip() + if text: + return text + return None + + +def _extract_tool_details(rows: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: + """Collect tool request/response details keyed by call ID.""" + details: dict[str, dict[str, Any]] = {} + for row in rows: + call_id = str(row.get("callId", "")).strip() + if not call_id: + continue + + entry = details.setdefault(call_id, {}) + if row.get("chunkType") == "toolRequest": + entry["arguments"] = row.get("arguments") + elif row.get("chunkType") == "toolResponse": + entry["toolResult"] = row.get("result") + error_message = row.get("errorMessage") or row.get("error") or row.get("message") + if error_message: + entry["errorMessage"] = error_message + return details + + +def _build_duration_stats(values: list[int]) -> dict[str, Any]: + """Build aggregate statistics for a collection of durations.""" + if not values: + return { + "count": 0, + "meanMs": 0, + "medianMs": 0, + "stdDevMs": 0, + "minMs": 0, + "maxMs": 0, + "totalMs": 0, + "meanLabel": format_duration_ms(0), + "medianLabel": format_duration_ms(0), + "stdDevLabel": format_duration_ms(0), + "minLabel": format_duration_ms(0), + "maxLabel": format_duration_ms(0), + "totalLabel": format_duration_ms(0), + } + + mean_ms = int(round(mean(values))) + median_ms = int(round(median(values))) + std_dev_ms = int(round(pstdev(values))) if len(values) > 1 else 0 + min_ms = min(values) + max_ms = max(values) + total_ms = sum(values) + return { + "count": len(values), + "meanMs": mean_ms, + "medianMs": median_ms, + "stdDevMs": std_dev_ms, + "minMs": min_ms, + "maxMs": max_ms, + "totalMs": total_ms, + "meanLabel": format_duration_ms(mean_ms), + "medianLabel": format_duration_ms(median_ms), + "stdDevLabel": format_duration_ms(std_dev_ms), + "minLabel": format_duration_ms(min_ms), + "maxLabel": format_duration_ms(max_ms), + "totalLabel": format_duration_ms(total_ms), + } + + +def build_html_report_payload( + conversations: list[dict[str, Any]], + *, + include_think_time: bool = False, + min_think_time_ms: int = 5, +) -> dict[str, Any]: + """Build the JSON payload embedded into the standalone HTML report.""" + payload_conversations: list[dict[str, Any]] = [] + for conversation in conversations: + rows = extract_history_rows(conversation["data"]) + history_bounds = build_history_bounds(rows) + conversation_summary = _extract_conversation_summary(rows) + conversation_result = _extract_conversation_result(rows) + tool_details = _extract_tool_details(rows) + spans, first_start, last_end = build_tool_spans( + rows, + include_think_time=include_think_time, + min_think_time_ms=min_think_time_ms, + ) + if history_bounds is None: + continue + + payload_conversations.append( + { + "id": conversation["id"], + "label": conversation["label"], + "source": conversation["source"], + "conversationSummary": conversation_summary, + "conversationResult": conversation_result, + "raw": conversation["data"], + "historyBounds": { + "start": history_bounds.start.isoformat(), + "end": history_bounds.end.isoformat(), + "totalMs": history_bounds.total_ms, + "totalLabel": format_duration_ms(history_bounds.total_ms), + }, + "timeline": { + "start": first_start.isoformat() if first_start else None, + "end": last_end.isoformat() if last_end else None, + "totalMs": max(int((last_end - first_start).total_seconds() * 1000), 1) + if first_start and last_end + else 0, + "laneCount": max((span.lane for span in spans), default=-1) + 1, + "stepCount": max((span.step for span in spans), default=0), + "spans": [ + { + "lane": span.lane, + "step": span.step, + "name": span.name, + "callId": span.call_id, + "start": span.start.isoformat(), + "end": span.end.isoformat(), + "durationMs": span.duration_ms, + "durationLabel": format_duration_ms(span.duration_ms), + "offsetMs": span.offset_ms, + "offsetLabel": format_duration_ms(span.offset_ms), + "label": span.label, + "arguments": tool_details.get(span.call_id or "", {}).get("arguments", span.arguments), + "title": span.title, + "summarizedTitle": span.summarized_title, + "failed": span.failed, + "errorMessage": tool_details.get(span.call_id or "", {}).get( + "errorMessage", span.error_message + ), + "toolResult": tool_details.get(span.call_id or "", {}).get("toolResult"), + } + for span in spans + ], + }, + "summary": { + "toolCalls": sum(1 for span in spans if span.name != "thinkTime"), + "thinkTimeMs": sum(span.duration_ms for span in spans if span.name == "thinkTime"), + "toolTimeMs": sum(span.duration_ms for span in spans if span.name != "thinkTime"), + }, + } + ) + + run_times = [conv["historyBounds"]["totalMs"] for conv in payload_conversations] + tool_times = [conv["summary"]["toolTimeMs"] for conv in payload_conversations] + think_times = [conv["summary"]["thinkTimeMs"] for conv in payload_conversations] + tool_call_counts = [conv["summary"]["toolCalls"] for conv in payload_conversations] + tool_span_durations = [ + span["durationMs"] + for conv in payload_conversations + for span in conv["timeline"]["spans"] + if span["name"] != "thinkTime" + ] + + return { + "conversationCount": len(payload_conversations), + "includeThinkTime": include_think_time, + "overview": { + "toolCalls": sum(tool_call_counts), + "toolSpans": len(tool_span_durations), + "avgToolCallsPerConversation": round(mean(tool_call_counts), 2) if tool_call_counts else 0, + "runTime": _build_duration_stats(run_times), + "toolTime": _build_duration_stats(tool_times), + "thinkTime": _build_duration_stats(think_times), + "toolDuration": _build_duration_stats(tool_span_durations), + }, + "conversations": payload_conversations, + } + + +def render_html_report(payload: dict[str, Any]) -> str: + """Render a self-contained HTML report with embedded JSON payload.""" + payload_json = json.dumps(payload, indent=2) + title_suffix = "" + if payload.get("conversationCount") == 1 and payload.get("conversations"): + first = payload["conversations"][0] + summary_title = ((first.get("conversationSummary") or {}).get("title") or first.get("label") or "").strip() + if summary_title: + title_suffix = f" - {summary_title}" + return f""" + + + + + Dremio Chat Gantt Report{title_suffix} + + + +
+ + + + +""" + + +def write_html_report(path: Path, payload: dict[str, Any]) -> None: + """Write the standalone report to disk.""" + path.write_text(render_html_report(payload), encoding="utf-8") diff --git a/src/drs/commands/chat.py b/src/drs/commands/chat.py index 741dc00..c51b37d 100644 --- a/src/drs/commands/chat.py +++ b/src/drs/commands/chat.py @@ -39,6 +39,7 @@ from rich.text import Text from drs.chat_gantt import load_history_dump, render_tool_gantt +from drs.chat_gantt_html import build_html_report_payload, write_html_report from drs.chat_render import ( ChatRenderer, PlainRenderer, @@ -289,6 +290,29 @@ async def get_messages( raise handle_api_error(exc) from exc +async def get_all_messages(client: DremioClient, conversation_id: str, page_size: int = 200) -> dict: + """Retrieve the full message history for a conversation across pagination.""" + rows: list[dict[str, Any]] = [] + page_token: str | None = None + + try: + while True: + result = await client.get_conversation_messages( + conversation_id, + limit=page_size, + page_token=page_token, + ) + batch = result.get("data", result.get("messages", [])) + if isinstance(batch, list): + rows.extend(batch) + page_token = result.get("nextPageToken") + if not page_token: + break + return {"data": rows} + except httpx.HTTPStatusError as exc: + raise handle_api_error(exc) from exc + + async def delete_conversation(client: DremioClient, conversation_id: str) -> dict: """DELETE /agent/conversations/{id}""" try: @@ -898,6 +922,75 @@ def chat_gantt( raise typer.Exit(1) +@app.command("html") +def chat_html( + conversation_ids: list[str] = typer.Argument( + None, + help="Conversation IDs to include. Provide multiple IDs as positional arguments.", + ), + output_file: Path = typer.Option( + ..., + "--output", + "-o", + dir_okay=False, + help="Destination HTML file", + ), + dump_files: list[Path] = typer.Option( + [], + "--dump-file", + help="Chat history dump JSON file to include. Repeat to add multiple dumps.", + ), + think_time: bool = typer.Option( + False, + "--think-time", + help="Include synthetic think-time gaps between steps when the gap exceeds 5 ms", + ), +) -> None: + """Export one or more conversation histories as a standalone HTML Gantt report.""" + if not conversation_ids and not dump_files: + print_error("Provide at least one conversation ID or --dump-file input.") + raise typer.Exit(1) + + client = _get_client() if conversation_ids else None + + async def _run() -> list[dict[str, Any]]: + conversations: list[dict[str, Any]] = [] + try: + if client is not None: + for conversation_id in conversation_ids: + conversations.append( + { + "id": conversation_id, + "label": conversation_id, + "source": "conversation", + "data": await get_all_messages(client, conversation_id), + } + ) + for dump_file in dump_files: + conversations.append( + { + "id": dump_file.stem, + "label": dump_file.stem, + "source": str(dump_file), + "data": load_history_dump(dump_file), + } + ) + return conversations + finally: + if client is not None: + await client.close() + + try: + conversations = asyncio.run(_run()) + payload = build_html_report_payload(conversations, include_think_time=think_time) + write_html_report(output_file, payload) + except (DremioAPIError, OSError, ValueError, json.JSONDecodeError) as exc: + print_error(f"Unable to export chat HTML report: {exc}") + raise typer.Exit(1) + + Console().print(f"[green]Wrote chat report:[/] {output_file}") + + @app.command("delete") def chat_delete( conversation_id: str = typer.Argument(help="Conversation ID to delete"), diff --git a/tests/test_chat_gantt_html.py b/tests/test_chat_gantt_html.py new file mode 100644 index 0000000..018026e --- /dev/null +++ b/tests/test_chat_gantt_html.py @@ -0,0 +1,227 @@ +# +# Copyright (C) 2017-2026 Dremio Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Tests for standalone chat Gantt HTML export.""" + +from __future__ import annotations + +from drs.chat_gantt_html import build_html_report_payload, render_html_report + + +def test_build_html_report_payload_supports_multiple_conversations() -> None: + payload = build_html_report_payload( + [ + { + "id": "conv-1", + "label": "Conversation 1", + "source": "conversation", + "data": { + "data": [ + { + "chunkType": "model", + "name": "modelSqlAnswer", + "createdAt": "2026-07-31T12:10:36.000Z", + "result": { + "title": "Direct Reports to Myra Richmond", + "summary": "Three people report to Myra Richmond.", + }, + }, + { + "chunkType": "toolRequest", + "callId": "c1", + "name": "runSql", + "createdAt": "2026-07-31T12:10:33.467Z", + "arguments": {"sqlText": "select 1"}, + }, + { + "chunkType": "toolResponse", + "callId": "c1", + "name": "runSql", + "createdAt": "2026-07-31T12:10:35.270Z", + "result": {"rows": [["Lesley Ellis"]]}, + }, + ] + }, + }, + { + "id": "conv-2", + "label": "Conversation 2", + "source": "dump", + "data": { + "data": [ + { + "chunkType": "toolRequest", + "callId": "c2", + "name": "validateSql", + "createdAt": "2026-07-31T12:11:33.467Z", + "arguments": {"sqlText": "select 2"}, + }, + { + "chunkType": "toolResponse", + "callId": "c2", + "name": "validateSql", + "createdAt": "2026-07-31T12:11:34.270Z", + "result": {"rows": []}, + }, + ] + }, + }, + ] + ) + + assert payload["conversationCount"] == 2 + assert payload["overview"]["toolCalls"] == 2 + assert payload["overview"]["avgToolCallsPerConversation"] == 1 + assert payload["overview"]["runTime"]["count"] == 2 + assert payload["overview"]["toolDuration"]["count"] == 2 + assert payload["conversations"][0]["conversationResult"] == "Direct Reports to Myra Richmond\n\nThree people report to Myra Richmond." + assert payload["conversations"][0]["timeline"]["spans"][0]["callId"] == "c1" + assert payload["conversations"][0]["timeline"]["spans"][0]["toolResult"] == {"rows": [["Lesley Ellis"]]} + assert payload["conversations"][1]["timeline"]["spans"][0]["callId"] == "c2" + assert payload["conversations"][0]["conversationSummary"]["title"] == "Direct Reports to Myra Richmond" + assert payload["conversations"][0]["conversationSummary"]["summary"] == "Three people report to Myra Richmond." + + +def test_render_html_report_embeds_json_payload() -> None: + html = render_html_report( + { + "conversationCount": 1, + "includeThinkTime": False, + "overview": { + "toolCalls": 1, + "toolSpans": 1, + "avgToolCallsPerConversation": 1, + "runTime": { + "count": 1, + "meanMs": 1803, + "medianMs": 1803, + "stdDevMs": 0, + "minMs": 1803, + "maxMs": 1803, + "totalMs": 1803, + "meanLabel": "1.803s", + "medianLabel": "1.803s", + "stdDevLabel": "0.000s", + "minLabel": "1.803s", + "maxLabel": "1.803s", + "totalLabel": "1.803s", + }, + "toolTime": { + "count": 1, + "meanMs": 1803, + "medianMs": 1803, + "stdDevMs": 0, + "minMs": 1803, + "maxMs": 1803, + "totalMs": 1803, + "meanLabel": "1.803s", + "medianLabel": "1.803s", + "stdDevLabel": "0.000s", + "minLabel": "1.803s", + "maxLabel": "1.803s", + "totalLabel": "1.803s", + }, + "thinkTime": { + "count": 1, + "meanMs": 0, + "medianMs": 0, + "stdDevMs": 0, + "minMs": 0, + "maxMs": 0, + "totalMs": 0, + "meanLabel": "0.000s", + "medianLabel": "0.000s", + "stdDevLabel": "0.000s", + "minLabel": "0.000s", + "maxLabel": "0.000s", + "totalLabel": "0.000s", + }, + "toolDuration": { + "count": 1, + "meanMs": 1803, + "medianMs": 1803, + "stdDevMs": 0, + "minMs": 1803, + "maxMs": 1803, + "totalMs": 1803, + "meanLabel": "1.803s", + "medianLabel": "1.803s", + "stdDevLabel": "0.000s", + "minLabel": "1.803s", + "maxLabel": "1.803s", + "totalLabel": "1.803s", + }, + }, + "conversations": [ + { + "id": "conv-1", + "label": "Conversation 1", + "source": "conversation", + "conversationSummary": { + "title": "Direct Reports to Myra Richmond", + "summary": "Three people report to Myra Richmond.", + }, + "conversationResult": "Direct Reports to Myra Richmond\n\nThree people report to Myra Richmond.", + "raw": {"data": []}, + "historyBounds": { + "start": "2026-07-31T12:10:33.467+00:00", + "end": "2026-07-31T12:10:35.270+00:00", + "totalMs": 1803, + "totalLabel": "1.803s", + }, + "timeline": { + "start": "2026-07-31T12:10:33.467+00:00", + "end": "2026-07-31T12:10:35.270+00:00", + "totalMs": 1803, + "laneCount": 1, + "stepCount": 1, + "spans": [ + { + "lane": 0, + "step": 1, + "name": "runSql", + "callId": "c1", + "start": "2026-07-31T12:10:33.467+00:00", + "end": "2026-07-31T12:10:35.270+00:00", + "durationMs": 1803, + "durationLabel": "1.803s", + "offsetMs": 0, + "offsetLabel": "0.000s", + "label": "runSql", + "arguments": {"sqlText": "select 1"}, + "title": "Direct Reports to Myra Richmond", + "summarizedTitle": None, + "failed": False, + "errorMessage": None, + "toolResult": {"rows": [["Lesley Ellis"]]}, + } + ], + }, + "summary": {"toolCalls": 1, "thinkTimeMs": 0, "toolTimeMs": 1803}, + } + ], + } + ) + + assert "" in html + assert 'id="report-data"' in html + assert '"conversationCount": 1' in html + assert "Overview" in html + assert "Overall Stats" in html + assert "Conversation 1" in html + assert "Direct Reports to Myra Richmond" in html + assert "Three people report to Myra Richmond." in html + assert "Tool Result" in html + assert "Conversation Result" in html diff --git a/tests/test_cli.py b/tests/test_cli.py index 4af5829..45fdfae 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -143,3 +143,77 @@ async def fake_get_messages(client, conversation_id, limit=50): assert result.exit_code == 0 assert "S1 searchViewsAndTables" in result.output + + +def test_chat_html_command_from_dump(tmp_path) -> None: + dump_path = tmp_path / "history.json" + output_path = tmp_path / "report.html" + dump_path.write_text( + json.dumps( + { + "data": [ + { + "chunkType": "toolRequest", + "callId": "c1", + "name": "searchViewsAndTables", + "createdAt": "2026-07-13T12:39:29.860Z", + "arguments": {"arg0": "supplier contract risk exposure"}, + }, + { + "chunkType": "toolResponse", + "callId": "c1", + "name": "searchViewsAndTables", + "createdAt": "2026-07-13T12:39:32.719Z", + "result": {"rows": []}, + }, + ] + } + ), + encoding="utf-8", + ) + + result = runner.invoke(app, ["chat", "html", "-o", str(output_path), "--dump-file", str(dump_path)]) + + assert result.exit_code == 0 + report = output_path.read_text(encoding="utf-8") + assert "" in report + assert '"conversationCount": 1' in report + assert '"callId": "c1"' in report + + +def test_chat_html_command_accepts_positional_conversation_ids(monkeypatch, tmp_path) -> None: + from drs.commands import chat + + client = type("DummyClient", (), {"close": AsyncMock()})() + monkeypatch.setattr(chat, "_get_client", lambda: client) + + async def fake_get_all_messages(client, conversation_id, page_size=200): + return { + "data": [ + { + "chunkType": "toolRequest", + "callId": f"{conversation_id}-c1", + "name": "runSql", + "createdAt": "2026-07-31T12:10:33.467Z", + "arguments": {"sqlText": "select 1"}, + }, + { + "chunkType": "toolResponse", + "callId": f"{conversation_id}-c1", + "name": "runSql", + "createdAt": "2026-07-31T12:10:35.270Z", + "result": {"rows": []}, + }, + ] + } + + monkeypatch.setattr(chat, "get_all_messages", fake_get_all_messages) + + output_path = tmp_path / "report.html" + result = runner.invoke(app, ["chat", "html", "conv-1", "conv-2", "-o", str(output_path)]) + + assert result.exit_code == 0 + report = output_path.read_text(encoding="utf-8") + assert '"conversationCount": 2' in report + assert '"id": "conv-1"' in report + assert '"id": "conv-2"' in report From 9de9e83e3a94c5c3cb8655d810f1ba3fbd683857 Mon Sep 17 00:00:00 2001 From: Aniket Kulkarni Date: Wed, 5 Aug 2026 13:24:04 -0400 Subject: [PATCH 4/8] Add fallback version for non-git builds --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 8f3ae99..137e4b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,7 @@ build-backend = "hatchling.build" [tool.hatch.version] source = "vcs" +fallback-version = "0.0.0" [tool.hatch.build.hooks.vcs] version-file = "src/drs/_version.py" From 019051d298f682887a37aa01055237dfa34da14b Mon Sep 17 00:00:00 2001 From: Aniket Kulkarni Date: Wed, 5 Aug 2026 13:28:31 -0400 Subject: [PATCH 5/8] Document chat debugging workflows --- README.md | 68 ++++++++++++++++++++++++++++++++++++++ src/drs/chat_gantt_html.py | 6 ++-- 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index aa43ef3..adf2d14 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,7 @@ If this returns `{"job_id": "...", "state": "COMPLETED", "rowCount": 1, "rows": | `dremio role` | `list`, `get`, `create`, `update`, `delete` | Full CRUD for organization roles | | `dremio grant` | `get`, `update`, `delete` | Manage grants on projects, engines, org resources | | `dremio project` | `list`, `get`, `create`, `update`, `delete` | Full CRUD for Dremio Cloud projects | +| `dremio chat` | `list`, `history`, `gantt`, `html`, interactive chat | Work with AI agent conversations, tool traces, timelines, and standalone reports | | `dremio search` | *(top-level)* | Full-text search across all catalog entities | | `dremio describe` | *(top-level)* | Machine-readable schema for any command | @@ -172,6 +173,16 @@ dremio job list --status FAILED --output pretty # Audit what roles and permissions a user has dremio user audit rahim.bhojani + +# Show a conversation transcript with tool timestamps, durations, and detailed results +dremio chat history CONVERSATION_ID --show-tool-details + +# Render a terminal Gantt timeline for a saved chat history dump +dremio chat gantt ./history.json --ascii --think-time + +# Export one or more conversations to a standalone HTML report +dremio chat html conv-1 conv-2 -o report.html +dremio chat html -o report.html --dump-file ./history.json --dump-file ./history-2.json ``` ### Output formats @@ -209,6 +220,63 @@ dremio describe reflection.list Returns a JSON schema with parameter names, types, required/optional, and descriptions. Useful for building automation on top of `dremio`. +## Chat debugging workflows + +The `dremio chat` command group includes tools for investigating AI agent runs after the fact, both in the terminal and in a standalone HTML report. + +### Detailed transcript output + +Use `--show-tool-details` with one-shot chat or chat history to include: + +- Tool start and finish timestamps +- Per-tool duration +- Full tool results instead of a compact `done` line + +```bash +# One-shot chat with full tool details +dremio chat -m "who reports to Myra Richmond" --show-tool-details + +# Existing conversation transcript with full tool details +dremio chat history CONVERSATION_ID --show-tool-details +``` + +### Terminal Gantt timelines + +Use `dremio chat gantt` for a saved history dump, or `dremio chat history --gantt` for a live conversation history. + +```bash +# Render plain ASCII output +dremio chat gantt ./history.json --ascii + +# Include synthetic think-time gaps between steps +dremio chat history CONVERSATION_ID --gantt --ascii --think-time +``` + +The Textual Gantt viewer adapts its foreground colors to light and dark terminal themes and uses transparent backgrounds so it blends into the terminal instead of forcing its own surface color. + +### Standalone HTML report + +Use `dremio chat html` to export a self-contained report with all data embedded in the page. + +```bash +# Export multiple conversation IDs +dremio chat html conv-1 conv-2 conv-3 -o report.html + +# Export one or more local history dumps +dremio chat html -o report.html --dump-file ./history.json --dump-file ./history-2.json + +# Include think-time spans in the report +dremio chat html conv-1 -o report.html --think-time +``` + +The HTML report includes: + +- An overview page with aggregate stats across all included conversations +- Mean, median, standard deviation, min, max, and totals for run time, tool time, think time, and tool durations +- Navigation from the overview page into each individual conversation timeline +- Conversation summary and result content +- Selected-span payload and tool result details + ## CRUD design principle Every Dremio object has consistent CLI commands using standard CRUD verbs (`list`, `get`, `create`, `update`, `delete`): diff --git a/src/drs/chat_gantt_html.py b/src/drs/chat_gantt_html.py index 3aa8a64..89d76bc 100644 --- a/src/drs/chat_gantt_html.py +++ b/src/drs/chat_gantt_html.py @@ -108,9 +108,9 @@ def _build_duration_stats(values: list[int]) -> dict[str, Any]: "totalLabel": format_duration_ms(0), } - mean_ms = int(round(mean(values))) - median_ms = int(round(median(values))) - std_dev_ms = int(round(pstdev(values))) if len(values) > 1 else 0 + mean_ms = round(mean(values)) + median_ms = round(median(values)) + std_dev_ms = round(pstdev(values)) if len(values) > 1 else 0 min_ms = min(values) max_ms = max(values) total_ms = sum(values) From 82b87e33c33da2ac7a2ac51c950ff64f165d1fe1 Mon Sep 17 00:00:00 2001 From: Aniket Kulkarni Date: Wed, 5 Aug 2026 13:31:13 -0400 Subject: [PATCH 6/8] Format chat gantt report files --- src/drs/chat_gantt_html.py | 4 ++-- src/drs/chat_gantt_tui.py | 4 +++- tests/test_chat_gantt_html.py | 5 ++++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/drs/chat_gantt_html.py b/src/drs/chat_gantt_html.py index 89d76bc..be77a22 100644 --- a/src/drs/chat_gantt_html.py +++ b/src/drs/chat_gantt_html.py @@ -770,7 +770,7 @@ def render_html_report(payload: dict[str, Any]) -> str: if (normalized.startsWith("|")) normalized = normalized.slice(1); if (normalized.endsWith("|")) normalized = normalized.slice(0, -1); const cells = normalized.split("|").map((cell) => cell.trim()); - return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/.test(cell)); + return cells.length > 0 && cells.every((cell) => /^:?-{{3,}}:?$/.test(cell)); }} function splitTableRow(line) {{ @@ -837,7 +837,7 @@ def render_html_report(payload: dict[str, Any]) -> str: continue; }} - const headingMatch = trimmed.match(/^(#{1,6})\\s+(.*)$/); + const headingMatch = trimmed.match(/^(#{{1,6}})\\s+(.*)$/); if (headingMatch) {{ flushParagraph(); flushList(); diff --git a/src/drs/chat_gantt_tui.py b/src/drs/chat_gantt_tui.py index 9d96a92..5f452f2 100644 --- a/src/drs/chat_gantt_tui.py +++ b/src/drs/chat_gantt_tui.py @@ -505,7 +505,9 @@ def on_mount(self) -> None: (truncate_label(span.name, 22), "red" if span.failed else palette.label), ) offset_cell = format_duration_ms(span.offset_ms) - duration_cell = Text(format_duration_ms(span.duration_ms), style="red" if span.failed else palette.label) + duration_cell = Text( + format_duration_ms(span.duration_ms), style="red" if span.failed else palette.label + ) table.add_row( str(span.step), tool_cell, diff --git a/tests/test_chat_gantt_html.py b/tests/test_chat_gantt_html.py index 018026e..4fae507 100644 --- a/tests/test_chat_gantt_html.py +++ b/tests/test_chat_gantt_html.py @@ -86,7 +86,10 @@ def test_build_html_report_payload_supports_multiple_conversations() -> None: assert payload["overview"]["avgToolCallsPerConversation"] == 1 assert payload["overview"]["runTime"]["count"] == 2 assert payload["overview"]["toolDuration"]["count"] == 2 - assert payload["conversations"][0]["conversationResult"] == "Direct Reports to Myra Richmond\n\nThree people report to Myra Richmond." + assert ( + payload["conversations"][0]["conversationResult"] + == "Direct Reports to Myra Richmond\n\nThree people report to Myra Richmond." + ) assert payload["conversations"][0]["timeline"]["spans"][0]["callId"] == "c1" assert payload["conversations"][0]["timeline"]["spans"][0]["toolResult"] == {"rows": [["Lesley Ellis"]]} assert payload["conversations"][1]["timeline"]["spans"][0]["callId"] == "c2" From d9d1299d06d203c09c88814d0ca8b85eb6099427 Mon Sep 17 00:00:00 2001 From: Aniket Kulkarni Date: Wed, 5 Aug 2026 13:42:53 -0400 Subject: [PATCH 7/8] Address PR review feedback --- src/drs/chat_gantt_html.py | 7 ++++++- src/drs/commands/chat.py | 18 +++++++++++++----- tests/test_chat_gantt_html.py | 19 +++++++++++++++++++ tests/test_commands/test_chat.py | 23 +++++++++++++++++++++++ 4 files changed, 61 insertions(+), 6 deletions(-) diff --git a/src/drs/chat_gantt_html.py b/src/drs/chat_gantt_html.py index be77a22..48c1f3f 100644 --- a/src/drs/chat_gantt_html.py +++ b/src/drs/chat_gantt_html.py @@ -237,7 +237,12 @@ def build_html_report_payload( def render_html_report(payload: dict[str, Any]) -> str: """Render a self-contained HTML report with embedded JSON payload.""" - payload_json = json.dumps(payload, indent=2) + payload_json = ( + json.dumps(payload, indent=2) + .replace("&", "\\u0026") + .replace("<", "\\u003c") + .replace(">", "\\u003e") + ) title_suffix = "" if payload.get("conversationCount") == 1 and payload.get("conversations"): first = payload["conversations"][0] diff --git a/src/drs/commands/chat.py b/src/drs/commands/chat.py index c51b37d..5cb03fe 100644 --- a/src/drs/commands/chat.py +++ b/src/drs/commands/chat.py @@ -369,14 +369,22 @@ def _build_approval_payload(nonce: str, tools: list[dict[str, Any]], auto_approv execution_id = tool.get("executionId", tool.get("callId", tool.get("id", ""))) name = tool.get("name", "") arguments = tool.get("arguments", {}) - if not execution_id or not name: + if not execution_id: + continue + if name: + decisions.append( + { + "executionId": execution_id, + "name": name, + "arguments": arguments if isinstance(arguments, dict) else {}, + "approved": auto_approve, + } + ) continue decisions.append( { - "executionId": execution_id, - "name": name, - "arguments": arguments if isinstance(arguments, dict) else {}, - "approved": auto_approve, + "callId": execution_id, + "decision": "approved" if auto_approve else "denied", } ) return { diff --git a/tests/test_chat_gantt_html.py b/tests/test_chat_gantt_html.py index 4fae507..8f3b39d 100644 --- a/tests/test_chat_gantt_html.py +++ b/tests/test_chat_gantt_html.py @@ -228,3 +228,22 @@ def test_render_html_report_embeds_json_payload() -> None: assert "Three people report to Myra Richmond." in html assert "Tool Result" in html assert "Conversation Result" in html + + +def test_render_html_report_escapes_script_terminators_in_payload() -> None: + html = render_html_report( + { + "conversationCount": 1, + "conversations": [ + { + "id": "conv-1", + "label": "Conversation 1", + "conversationSummary": {"title": "Result"}, + "conversationResult": "", + } + ], + } + ) + + assert "" not in html + assert "\\u003c/script\\u003e\\u003cscript\\u003ealert(1)\\u003c/script\\u003e" in html diff --git a/tests/test_commands/test_chat.py b/tests/test_commands/test_chat.py index 8af9bff..d2befd5 100644 --- a/tests/test_commands/test_chat.py +++ b/tests/test_commands/test_chat.py @@ -141,6 +141,29 @@ def test_build_approval_payload_uses_v2_shape() -> None: } +def test_build_approval_payload_preserves_legacy_interrupt_shape() -> None: + approvals = _build_approval_payload( + "nonce-1", + [ + { + "callId": "legacy-1", + "decision": "approved", + } + ], + auto_approve=False, + ) + + assert approvals == { + "approvalNonce": "nonce-1", + "toolDecisions": [ + { + "callId": "legacy-1", + "decision": "denied", + } + ], + } + + @pytest.mark.asyncio async def test_list_conversations(mock_client) -> None: mock_client.list_conversations = AsyncMock( From e6eed6617b258d0b8a6c3dee1046facf72925d80 Mon Sep 17 00:00:00 2001 From: Aniket Kulkarni Date: Wed, 5 Aug 2026 14:28:30 -0400 Subject: [PATCH 8/8] Format chat HTML report file --- src/drs/chat_gantt_html.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/drs/chat_gantt_html.py b/src/drs/chat_gantt_html.py index 48c1f3f..5936e9e 100644 --- a/src/drs/chat_gantt_html.py +++ b/src/drs/chat_gantt_html.py @@ -237,12 +237,7 @@ def build_html_report_payload( def render_html_report(payload: dict[str, Any]) -> str: """Render a self-contained HTML report with embedded JSON payload.""" - payload_json = ( - json.dumps(payload, indent=2) - .replace("&", "\\u0026") - .replace("<", "\\u003c") - .replace(">", "\\u003e") - ) + payload_json = json.dumps(payload, indent=2).replace("&", "\\u0026").replace("<", "\\u003c").replace(">", "\\u003e") title_suffix = "" if payload.get("conversationCount") == 1 and payload.get("conversations"): first = payload["conversations"][0]