From 0decd2764ea15b6c9b5ab326c88980644b8a618f Mon Sep 17 00:00:00 2001 From: Aakash Kattelu Date: Mon, 10 Aug 2026 10:24:12 -0400 Subject: [PATCH] feat(cli): add `honcho session view` transcript command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a read-only transcript view for a session, with three paging modes: a tail window (`--last N`, the default), server pages (`--page N --size M`), and the whole conversation (`--all`). `--reverse` selects newest-first in every mode, `--ids` exposes message IDs, and `-p` scopes to one peer. JSON mode emits the same shape as `message list`. The renderer is deliberately literal: content and identifiers go through `rich.text.Text` rather than Markdown or console markup, so newlines, tag delimiters like ``, and bracketed text survive intact — this is a debugging surface, so it has to show what was actually stored. Timestamps are converted to UTC (not just stripped of their offset) and keep millisecond precision. Nothing is truncated with an ellipsis: a displayed message ID is always usable with `honcho message get`. Flags are validated before the client is built, and the session is constructed directly instead of via the get-or-create `client.session()`, so an invalid or mistyped invocation never reaches — or creates — anything server-side. `--size` is bounded locally to the server's 100-item ceiling rather than surfacing a raw 422, and the "more:" hint echoes back the size and ordering actually in use so following it lands on the adjacent window. Also fixes `honcho message list --last N`, which stopped at the first page of 50: both commands now share the page-walking helper, so the same flag returns the same window either way. Co-Authored-By: Claude Opus 5 --- .claude/skills/honcho-cli/SKILL.md | 4 +- honcho-cli/CHANGELOG.md | 10 + honcho-cli/README.md | 1 + honcho-cli/src/honcho_cli/_help.py | 2 +- honcho-cli/src/honcho_cli/commands/message.py | 7 +- honcho-cli/src/honcho_cli/commands/session.py | 196 +++++++++++++++- honcho-cli/src/honcho_cli/output.py | 124 +++++++++++ honcho-cli/tests/test_commands.py | 209 ++++++++++++++++++ honcho-cli/tests/test_output.py | 139 ++++++++++++ 9 files changed, 683 insertions(+), 9 deletions(-) create mode 100644 honcho-cli/tests/test_output.py diff --git a/.claude/skills/honcho-cli/SKILL.md b/.claude/skills/honcho-cli/SKILL.md index e2276669..67e71ff7 100644 --- a/.claude/skills/honcho-cli/SKILL.md +++ b/.claude/skills/honcho-cli/SKILL.md @@ -20,7 +20,7 @@ allowed-tools: Bash(honcho:*), Bash(jq:*), Read, Grep - `honcho config` — CLI configuration - `honcho workspace` — inspect, delete, search - `honcho peer` — inspect, card, chat, search -- `honcho session` — inspect, messages, context, summaries +- `honcho session` — inspect, view (transcript), context, summaries - `honcho message` — list and get - `honcho conclusion` — list, search, create, delete @@ -62,6 +62,8 @@ honcho conclusion search "topic" --observer --json ```bash honcho session inspect --json +honcho session view --last 20 --json +honcho session view --page 2 --size 50 --json honcho message list --last 20 --json honcho session context --json honcho session summaries --json diff --git a/honcho-cli/CHANGELOG.md b/honcho-cli/CHANGELOG.md index 3c2c383a..70900497 100644 --- a/honcho-cli/CHANGELOG.md +++ b/honcho-cli/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [Unreleased] + +### Added + +- `honcho session view` — session transcript table (`--last N`, `--page N --size M`, `--all`, `--reverse`, `--ids`, peer filter via `-p`). Content is shown verbatim, timestamps are normalized to UTC, and the command is read-only: unlike the other session commands it never get-or-creates the session + +### Fixed + +- `honcho message list --last N` no longer stops at the first page of 50 — it walks pages to fill the requested window + ## [0.1.2] - 2026-07-20 ### Added diff --git a/honcho-cli/README.md b/honcho-cli/README.md index 804e2285..b191bcdb 100644 --- a/honcho-cli/README.md +++ b/honcho-cli/README.md @@ -71,6 +71,7 @@ Per-command scoping (workspace / peer / session) is handled via `-w` / `-p` / `- | `honcho session list` | List sessions in the workspace (filter with `--peer/-p`) | | `honcho session create ` | Create or get a session (optionally `--peers` to add peers, `--metadata`) | | `honcho session inspect ` | Peers, message count, summaries, config | +| `honcho session view ` | Transcript table (`--last N`, `--page N --size M`, `--all`, `--reverse`, `--ids`, `-p`) | | `honcho session context ` | What an agent would see | | `honcho session summaries ` | Short + long summaries | | `honcho session peers ` / `add-peers` / `remove-peers` | Peer management | diff --git a/honcho-cli/src/honcho_cli/_help.py b/honcho-cli/src/honcho_cli/_help.py index d5dcb976..d47d344d 100644 --- a/honcho-cli/src/honcho_cli/_help.py +++ b/honcho-cli/src/honcho_cli/_help.py @@ -76,7 +76,7 @@ def print_welcome(console: Console) -> None: ("workspace", "list · create · search · delete · inspect · queue-status"), ("peer", "list · create · search · inspect · card · chat"), ("", "get-metadata · set-metadata · representation"), - ("session", "list · create · search · delete · inspect · add-peers"), + ("session", "list · create · search · delete · inspect · view · add-peers"), ("", "context · get-metadata · set-metadata · peers"), ("", "remove-peers · representation · summaries"), ("message", "list · create · get"), diff --git a/honcho-cli/src/honcho_cli/commands/message.py b/honcho-cli/src/honcho_cli/commands/message.py index b810917a..2742556c 100644 --- a/honcho-cli/src/honcho_cli/commands/message.py +++ b/honcho-cli/src/honcho_cli/commands/message.py @@ -10,7 +10,7 @@ import typer from honcho.api_types import MessageCreateParams -from honcho_cli.commands.session import _get_session_id +from honcho_cli.commands.session import _fetch_recent_messages, _get_session_id from honcho_cli.commands.workspace import _handle_error from honcho_cli.output import print_error, print_result, status from honcho_cli.validation import validate_resource_id @@ -42,10 +42,11 @@ def list_messages( try: filters = {"peer_id": config.peer_id} if config.peer_id else None - # Fetch newest-first so [:last] always gives the most recent N messages, + # Fetch newest-first so we always get the most recent N messages — the + # shared helper walks pages, so --last above one page isn't truncated — # then flip to oldest-at-top / newest-at-bottom for readable display. # --reverse keeps the raw server order (oldest first, descending in table). - msgs = sess.messages(filters=filters, reverse=True).items[:last] + msgs, _ = _fetch_recent_messages(sess, filters, last) if not reverse: msgs = list(reversed(msgs)) diff --git a/honcho-cli/src/honcho_cli/commands/session.py b/honcho-cli/src/honcho_cli/commands/session.py index 2c5d811b..861d4bb8 100644 --- a/honcho-cli/src/honcho_cli/commands/session.py +++ b/honcho-cli/src/honcho_cli/commands/session.py @@ -1,4 +1,4 @@ -"""Session commands: list, inspect, context, summaries, peers, search, representation, metadata.""" +"""Session commands: list, inspect, view, context, summaries, peers, search, representation, metadata.""" from __future__ import annotations @@ -7,16 +7,16 @@ from typing import List, Optional import typer -from honcho import HonchoError +from honcho import HonchoError, Session from honcho_cli.commands.workspace import _config_to_dict, _handle_error, _raw_list -from honcho_cli.output import print_error, print_result, status, use_json +from honcho_cli.output import print_error, print_result, print_transcript, status, use_json from honcho_cli.validation import validate_resource_id from honcho_cli._help import HonchoTyperGroup from honcho_cli.common import add_common_options, get_client, get_resolved_config, handle_cmd_flags -app = typer.Typer(cls=HonchoTyperGroup, help="List, inspect, create, delete, and manage conversation sessions and their peers.") +app = typer.Typer(cls=HonchoTyperGroup, help="List, inspect, view, create, delete, and manage conversation sessions and their peers.") add_common_options(app) @@ -135,6 +135,194 @@ def inspect( _handle_error(e, "session", sid) +# Server-side ceiling on page size (fastapi-pagination's default ``Params`` +# declares ``size`` as ``Query(50, ge=1, le=100)``), enforced locally so an +# out-of-range --size is a CLI error rather than an opaque 422. +MAX_PAGE_SIZE = 100 +DEFAULT_PAGE_SIZE = 50 + + +def _fetch_recent_messages(sess, filters: dict | None, last: int) -> tuple[list, int | None]: + """Fetch the ``last`` most recent messages, newest first. + + Walks as many newest-first server pages as it takes to fill the window, so + ``last`` above the page cap is honored instead of silently truncated. + Returns the messages plus the session's total message count (if reported). + """ + page = sess.messages( + filters=filters, + reverse=True, + size=min(max(last, 1), MAX_PAGE_SIZE), + ) + total = page.total + msgs = list(page.items) + while len(msgs) < last and page.has_next_page(): + page = page.get_next_page() + if page is None: + break + msgs.extend(page.items) + return msgs[:last], total + + +def _fetch_all_messages(sess, filters: dict | None) -> tuple[list, int | None]: + """Fetch every message in the session, oldest first.""" + page = sess.messages(filters=filters, reverse=False, size=MAX_PAGE_SIZE) + total = page.total + msgs = list(page.items) + while page.has_next_page(): + page = page.get_next_page() + if page is None: + break + msgs.extend(page.items) + return msgs, total + + +@app.command() +def view( + session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"), + last: Optional[int] = typer.Option( + None, + "--last", + help=f"Show only the N most recent messages (default when no --page/--all: {DEFAULT_PAGE_SIZE})", + ), + page_number: Optional[int] = typer.Option( + None, + "--page", + help="1-indexed page of the full transcript. Use for page 2+.", + ), + size: Optional[int] = typer.Option( + None, + "--size", + help=f"Messages per page; requires --page (1-{MAX_PAGE_SIZE}, default: {DEFAULT_PAGE_SIZE})", + ), + all_messages: bool = typer.Option(False, "--all", help="Show the full transcript (every page)"), + reverse: bool = typer.Option( + False, + "--reverse", + help="Newest first (default is chronological: oldest at top)", + ), + show_ids: bool = typer.Option(False, "--ids", help="Include message IDs in the transcript"), + workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"), + peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Filter by peer ID"), + session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"), + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), +) -> None: + """View a session transcript as a chat log. + + Modes (pick one): + + - default / ``--last N`` — tail of the conversation (most recent N) + - ``--page N [--size M]`` — page through the full transcript + - ``--all`` — every message + + Paging follows the requested order: ``--page 1`` starts at the oldest + message, or the newest with ``--reverse``. + + Human mode prints a row-delimited table. JSON mode emits the message list + (same shape as ``message list``). + """ + handle_cmd_flags(json_output=json_output, workspace=workspace, peer=peer, session=session) + sid = _get_session_id(session_id) + + # Validate every flag before touching the network, so a bad invocation never + # reaches the API. + modes = sum([ + last is not None, + page_number is not None, + all_messages, + ]) + if modes > 1: + print_error( + "INVALID_FLAGS", + "--last, --page, and --all are mutually exclusive", + {"last": last, "page": page_number, "all": all_messages}, + ) + raise typer.Exit(1) + + if page_number is not None and page_number < 1: + print_error("INVALID_FLAGS", "--page must be >= 1", {"page": page_number}) + raise typer.Exit(1) + if size is not None and page_number is None: + print_error("INVALID_FLAGS", "--size only applies with --page", {"size": size}) + raise typer.Exit(1) + if size is not None and not 1 <= size <= MAX_PAGE_SIZE: + print_error( + "INVALID_FLAGS", + f"--size must be between 1 and {MAX_PAGE_SIZE}", + {"size": size}, + ) + raise typer.Exit(1) + if last is not None and last < 1: + print_error("INVALID_FLAGS", "--last must be >= 1", {"last": last}) + raise typer.Exit(1) + + # Default: tail of conversation (most recent 50), matching prior behavior. + mode = "page" if page_number is not None else ("all" if all_messages else "last") + tail = last if last is not None else DEFAULT_PAGE_SIZE + page_size = size if size is not None else DEFAULT_PAGE_SIZE + + client, config = get_client() + # Read-only: build the Session directly rather than via client.session(), + # which is a get-or-create POST and would create a session on a typo. + sess = Session(sid, client) + + try: + filters = {"peer_id": config.peer_id} if config.peer_id else None + page_meta: int | None = None + pages_meta: int | None = None + + if mode == "page": + # Page in the order the caller asked for, so --reverse --page 1 is + # the newest page rather than the oldest one displayed backwards. + result_page = sess.messages( + filters=filters, + page=page_number, + size=page_size, + reverse=reverse, + ) + msgs = list(result_page.items) + total = result_page.total + page_meta = result_page.page if result_page.page is not None else page_number + pages_meta = result_page.pages + elif mode == "all": + msgs, total = _fetch_all_messages(sess, filters) + if reverse: + msgs = list(reversed(msgs)) + else: + # Tail window: fetched newest-first, flipped to chronological unless --reverse. + msgs, total = _fetch_recent_messages(sess, filters, tail) + if not reverse: + msgs = list(reversed(msgs)) + + items = [ + { + "id": m.id, + "peer_id": m.peer_id, + "content": m.content, + "token_count": m.token_count, + "metadata": m.metadata, + "created_at": str(m.created_at), + } + for m in msgs + ] + except Exception as e: + _handle_error(e, "session", sid) + raise # unreachable: _handle_error always exits + + # Rendered outside the try so an output-side failure (e.g. a broken pipe + # from `| head`) isn't reported as a session API error. + print_transcript( + items, + session_id=sid, + total=total, + page=page_meta, + pages=pages_meta, + size=page_size if mode == "page" else None, + reverse=reverse, + show_ids=show_ids, + ) + + @app.command() def context( session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"), diff --git a/honcho-cli/src/honcho_cli/output.py b/honcho-cli/src/honcho_cli/output.py index de7902b8..5c141e28 100644 --- a/honcho-cli/src/honcho_cli/output.py +++ b/honcho-cli/src/honcho_cli/output.py @@ -8,10 +8,12 @@ from __future__ import annotations import json import os import sys +from datetime import datetime, timezone from typing import Any from rich.console import Console from rich.table import Table +from rich.text import Text console = Console(stderr=True) stdout_console = Console() @@ -102,3 +104,125 @@ def print_error(code: str, message: str, details: dict | None = None) -> None: def status(msg: str) -> None: """Print a status message to stderr.""" console.print(f"[dim]{msg}[/dim]") + + +# Stable peer-color palette for transcript rendering. Brand blue first so the +# primary peer lands on brand when there's only one speaker. +_PEER_COLORS = ( + "#B6DAFD", # brand + "#9ccfd8", # foam + "#c4a7e7", # iris + "#ebbcba", # rose + "#f6c177", # gold + "#a3be8c", # pine-ish green + "#ea9a97", # love +) + + +#: Rendered width of :func:`_format_timestamp` output. +TIMESTAMP_WIDTH = len("2026-01-01T00:00:00.000Z") + + +def _format_timestamp(value: Any) -> str: + """Compact UTC timestamp: ``YYYY-MM-DDTHH:MM:SS.mmmZ``. + + Offsets are *converted* to UTC rather than dropped, so a non-UTC + ``created_at`` isn't relabelled as UTC. Naive values are assumed UTC, which + is what the API returns. Millisecond precision is kept so messages within + the same second stay distinguishable. Unparseable values pass through + verbatim. + """ + if value is None: + return "" + if isinstance(value, datetime): + parsed = value + else: + try: + parsed = datetime.fromisoformat(str(value).strip()) + except ValueError: + return str(value).strip() + if parsed.tzinfo is not None: + parsed = parsed.astimezone(timezone.utc) + return f"{parsed:%Y-%m-%dT%H:%M:%S}.{parsed.microsecond // 1000:03d}Z" + + +def print_transcript( + messages: list[dict[str, Any]], + *, + session_id: str, + total: int | None = None, + page: int | None = None, + pages: int | None = None, + size: int | None = None, + reverse: bool = False, + show_ids: bool = False, +) -> None: + """Render a session transcript as a row-delimited table, or JSON. + + Each message dict must have ``peer_id``, ``content``, ``created_at``; + ``id`` is optional and only shown when ``show_ids`` is set. ``size`` and + ``reverse`` are echoed back in the next-page hint so following it lands on + the adjacent window rather than a differently-sized one. + """ + if use_json(): + print_json(messages) + return + + shown = len(messages) + parts = [f"session {session_id}"] + if page is not None and pages is not None: + parts.append(f"page {page}/{pages}") + elif page is not None: + parts.append(f"page {page}") + if total is not None and shown != total: + parts.append(f"showing {shown} of {total}") + else: + parts.append(f"{shown} message{'s' if shown != 1 else ''}") + title = " · ".join(parts) + + if not messages: + stdout_console.print(f"[dim]── {title} ──[/dim]") + stdout_console.print("[dim] (empty)[/dim]") + return + + table = Table( + title=title, + show_header=True, + header_style="bold", + show_lines=True, # delimiters between rows + expand=True, + pad_edge=False, + ) + # time is fixed-width ISO-UTC; ids and peers wrap rather than truncate so a + # displayed ID is always a usable one; content takes the rest. + table.add_column("time", style="dim", no_wrap=True, width=TIMESTAMP_WIDTH) + if show_ids: + table.add_column("id", style="dim", no_wrap=True) + table.add_column("peer", overflow="fold", max_width=24) + table.add_column("content", overflow="fold", ratio=1, min_width=40) + + peer_color: dict[str, str] = {} + for msg in messages: + peer = str(msg.get("peer_id") or "?") + if peer not in peer_color: + peer_color[peer] = _PEER_COLORS[len(peer_color) % len(_PEER_COLORS)] + + # Content and IDs go through Text, not Markdown or console markup: this is + # a debugging view, so it must show exactly what was stored — Markdown + # would reflow newlines and swallow the ``-style tags that fill + # agent transcripts, and markup would eat bracketed text. + row: list[Any] = [_format_timestamp(msg.get("created_at"))] + if show_ids: + row.append(Text(str(msg.get("id") or ""))) + row.append(Text(peer, style=f"bold {peer_color[peer]}")) + row.append(Text(str(msg.get("content") or ""))) + table.add_row(*row) + + stdout_console.print(table) + if page is not None and pages is not None and page < pages: + hint = f"more: honcho session view {session_id} --page {page + 1}" + if size is not None: + hint += f" --size {size}" + if reverse: + hint += " --reverse" + status(hint) diff --git a/honcho-cli/tests/test_commands.py b/honcho-cli/tests/test_commands.py index 61de7d47..64fbe2da 100644 --- a/honcho-cli/tests/test_commands.py +++ b/honcho-cli/tests/test_commands.py @@ -9,6 +9,7 @@ from __future__ import annotations import json import os +from contextlib import ExitStack, contextmanager from unittest.mock import MagicMock, patch import pytest @@ -34,6 +35,62 @@ def runner(): return CliRunner() +# --------------------------------------------------------------------------- # +# Helpers for `session view` — a fake SDK message, page, and Session + +def _view_msg(i: int) -> MagicMock: + return MagicMock( + id=f"m{i}", + peer_id="alice" if i % 2 == 0 else "bob", + content=f"msg-{i}", + token_count=i, + metadata={}, + created_at=f"2026-01-01T00:00:00.{i:03d}Z", + ) + + +def _fake_page( + items: list, + *, + total: int | None = None, + page: int | None = None, + pages: int | None = None, + has_next: bool = False, + next_page: MagicMock | None = None, +) -> MagicMock: + """A stand-in for the SDK's ``SyncPage`` with explicit (non-mock) metadata.""" + fake = MagicMock() + fake.items = items + fake.total = total + fake.page = page + fake.pages = pages + fake.has_next_page.return_value = has_next + fake.get_next_page.return_value = next_page + return fake + + +def _fake_session(page: MagicMock) -> MagicMock: + session = MagicMock() + session.messages.return_value = page + return session + + +def _patch_view(session: MagicMock, *, peer_id: str = ""): + """Patch `session view`'s client + read-only Session construction.""" + client = MagicMock() + config = MagicMock(session_id="sess1", workspace_id="ws1", peer_id=peer_id) + return _nested( + patch("honcho_cli.commands.session.get_client", return_value=(client, config)), + patch("honcho_cli.commands.session.Session", return_value=session), + ) + + +@contextmanager +def _nested(*managers): + with ExitStack() as stack: + yield [stack.enter_context(m) for m in managers] + + # --------------------------------------------------------------------------- # # 1. `honcho init` end-to-end @@ -169,6 +226,158 @@ class TestJsonContract: "created_at": "2026-01-01T00:00:00Z", } + def test_session_view_json_is_chronological_window(self, cfg, runner): + """`session view` returns the most recent N messages oldest→newest by default.""" + cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"})) + + # Server returns newest-first when reverse=True (m4, m3, m2, m1, m0). + page = _fake_page([_view_msg(i) for i in range(4, -1, -1)], total=5) + session = _fake_session(page) + + with _patch_view(session): + result = runner.invoke( + app, + ["session", "view", "sess1", "--last", "3", "-w", "ws1", "--json"], + ) + + assert result.exit_code == 0, result.stderr + payload = json.loads(result.stdout) + # Most recent 3 (m4,m3,m2) flipped to chronological: m2, m3, m4. + assert [m["id"] for m in payload] == ["m2", "m3", "m4"] + assert [m["content"] for m in payload] == ["msg-2", "msg-3", "msg-4"] + session.messages.assert_called_once() + assert session.messages.call_args.kwargs["reverse"] is True + + def test_session_view_rejects_all_with_last(self, cfg, runner): + cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"})) + config = MagicMock(session_id="sess1", workspace_id="ws1", peer_id="") + with patch("honcho_cli.commands.session.get_client", return_value=(MagicMock(), config)): + result = runner.invoke( + app, + ["session", "view", "sess1", "--all", "--last", "10", "-w", "ws1"], + ) + assert result.exit_code == 1 + assert json.loads(result.stderr)["error"]["code"] == "INVALID_FLAGS" + + def test_session_view_page_fetches_exact_server_page(self, cfg, runner): + """`--page N --size M` hits the API page directly (oldest-first).""" + cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"})) + + # Page 2 contents, already chronological. + page = _fake_page( + [_view_msg(i) for i in (50, 51, 52)], + total=120, + page=2, + pages=3, + has_next=True, + ) + session = _fake_session(page) + + with _patch_view(session): + result = runner.invoke( + app, + ["session", "view", "sess1", "--page", "2", "--size", "50", "-w", "ws1", "--json"], + ) + + assert result.exit_code == 0, result.stderr + payload = json.loads(result.stdout) + assert [m["id"] for m in payload] == ["m50", "m51", "m52"] + session.messages.assert_called_once_with( + filters=None, + page=2, + size=50, + reverse=False, + ) + + def test_session_view_page_with_reverse_pages_from_newest(self, cfg, runner): + """`--reverse --page N` pages from the newest end, not the oldest one flipped.""" + cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"})) + page = _fake_page([_view_msg(i) for i in (9, 8, 7)], total=30, page=1, pages=10) + session = _fake_session(page) + + with _patch_view(session): + result = runner.invoke( + app, + ["session", "view", "sess1", "--page", "1", "--reverse", "-w", "ws1", "--json"], + ) + + assert result.exit_code == 0, result.stderr + assert session.messages.call_args.kwargs["reverse"] is True + # Server order is preserved: no local flip on top of a reversed fetch. + assert [m["id"] for m in json.loads(result.stdout)] == ["m9", "m8", "m7"] + + def test_session_view_rejects_page_with_last(self, cfg, runner): + cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"})) + config = MagicMock(session_id="sess1", workspace_id="ws1", peer_id="") + with patch("honcho_cli.commands.session.get_client", return_value=(MagicMock(), config)): + result = runner.invoke( + app, + ["session", "view", "sess1", "--page", "2", "--last", "10", "-w", "ws1"], + ) + assert result.exit_code == 1 + assert json.loads(result.stderr)["error"]["code"] == "INVALID_FLAGS" + + @pytest.mark.parametrize( + "args", + [ + ["--size", "10"], # --size without --page would be silently ignored + ["--page", "1", "--size", "500"], # over the server's 100 ceiling + ["--page", "0"], + ["--last", "0"], + ], + ) + def test_session_view_rejects_bad_flags_before_any_api_call(self, cfg, runner, args): + """Flag validation runs before the client is built, so nothing reaches the API.""" + cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"})) + with patch("honcho_cli.commands.session.get_client") as get_client: + result = runner.invoke(app, ["session", "view", "sess1", *args, "-w", "ws1"]) + assert result.exit_code == 1 + assert json.loads(result.stderr)["error"]["code"] == "INVALID_FLAGS" + get_client.assert_not_called() + + def test_session_view_does_not_create_the_session(self, cfg, runner): + """`view` is read-only: it must not use the get-or-create client.session().""" + cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"})) + session = _fake_session(_fake_page([_view_msg(1)], total=1)) + client = MagicMock() + config = MagicMock(session_id="sess1", workspace_id="ws1", peer_id="") + + with patch("honcho_cli.commands.session.get_client", return_value=(client, config)), \ + patch("honcho_cli.commands.session.Session", return_value=session) as session_cls: + result = runner.invoke(app, ["session", "view", "sess1", "-w", "ws1", "--json"]) + + assert result.exit_code == 0, result.stderr + client.session.assert_not_called() + session_cls.assert_called_once_with("sess1", client) + + def test_session_view_last_walks_pages_past_the_page_cap(self, cfg, runner): + """`--last N` above the 100-item server cap keeps walking instead of truncating.""" + cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"})) + + # Newest-first pages of 100: m149..m50, then m49..m0. + second = _fake_page([_view_msg(i) for i in range(49, -1, -1)], total=150) + first = _fake_page( + [_view_msg(i) for i in range(149, 49, -1)], + total=150, + has_next=True, + next_page=second, + ) + session = _fake_session(first) + + with _patch_view(session): + result = runner.invoke( + app, + ["session", "view", "sess1", "--last", "120", "-w", "ws1", "--json"], + ) + + assert result.exit_code == 0, result.stderr + payload = json.loads(result.stdout) + assert len(payload) == 120 + # Oldest of the 120-message tail first, newest last. + assert payload[0]["id"] == "m30" + assert payload[-1]["id"] == "m149" + assert session.messages.call_args.kwargs["size"] == 100 + # --------------------------------------------------------------------------- # # 4. Exit codes on error diff --git a/honcho-cli/tests/test_output.py b/honcho-cli/tests/test_output.py new file mode 100644 index 00000000..b136d99c --- /dev/null +++ b/honcho-cli/tests/test_output.py @@ -0,0 +1,139 @@ +"""Transcript rendering: timestamp normalization and content fidelity. + +`session view` is a debugging surface, so the human-mode table must show what +was actually stored — no Markdown reflow, no truncated identifiers. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from honcho_cli import output +from honcho_cli.output import _format_timestamp, print_transcript + + +@pytest.fixture +def render(monkeypatch, capsys): + """Render a transcript in human mode at a fixed width and return stdout.""" + monkeypatch.setattr(output, "is_tty", lambda: True) + monkeypatch.setattr(output, "_force_json", False) + + def _render(messages, width: int = 120, **kwargs): + monkeypatch.setattr(output, "stdout_console", output.Console(width=width, no_color=True)) + print_transcript(messages, **kwargs) + return capsys.readouterr().out + + return _render + + +def _msg(content: str = "hi", **overrides) -> dict: + return { + "id": "V1StGXR8_Z5jdHi6B-myT", + "peer_id": "alice", + "content": content, + "created_at": "2026-01-01T00:00:00Z", + **overrides, + } + + +class TestFormatTimestamp: + @pytest.mark.parametrize( + ("value", "expected"), + [ + ("2026-01-01T00:00:00Z", "2026-01-01T00:00:00.000Z"), + ("2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00.000Z"), + ("2026-01-01 00:00:00+00:00", "2026-01-01T00:00:00.000Z"), + ("2026-01-01 00:00:00", "2026-01-01T00:00:00.000Z"), + ("2026-01-01T00:00:00.080000Z", "2026-01-01T00:00:00.080Z"), + ], + ) + def test_normalizes_utc_shapes(self, value, expected): + assert _format_timestamp(value) == expected + + @pytest.mark.parametrize( + ("value", "expected"), + [ + ("2026-01-01T14:30:00-05:00", "2026-01-01T19:30:00.000Z"), + ("2026-01-01T14:30:00+02:00", "2026-01-01T12:30:00.000Z"), + ], + ) + def test_converts_offsets_instead_of_relabelling_them(self, value, expected): + """An offset must be converted to UTC, not dropped and stamped `Z`.""" + assert _format_timestamp(value) == expected + + def test_keeps_sub_second_precision(self): + """Messages inside the same second must stay distinguishable.""" + a = _format_timestamp("2026-01-01T00:00:03.000Z") + b = _format_timestamp("2026-01-01T00:00:03.080Z") + assert a != b + assert (a, b) == ("2026-01-01T00:00:03.000Z", "2026-01-01T00:00:03.080Z") + + def test_accepts_datetime_objects(self): + value = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + assert _format_timestamp(value) == "2026-01-01T12:00:00.000Z" + + @pytest.mark.parametrize("value", [None, ""]) + def test_empty_values_render_blank(self, value): + assert _format_timestamp(value) == "" + + def test_unparseable_values_pass_through(self): + assert _format_timestamp("not-a-date") == "not-a-date" + + def test_output_matches_the_declared_column_width(self): + assert len(_format_timestamp("2026-01-01T00:00:00Z")) == output.TIMESTAMP_WIDTH + + +class TestTranscriptFidelity: + def test_newlines_are_not_reflowed_into_a_paragraph(self, render): + out = render([_msg("line one\nline two\nline three")], session_id="s1") + assert "line one line two line three" not in out + for line in ("line one", "line two", "line three"): + assert line in out + + def test_tagged_content_is_not_stripped(self, render): + """Agent transcripts are full of ``-style tags; they must survive.""" + out = render([_msg("reasoning answer")], session_id="s1") + assert "" in out + assert "" in out + + def test_console_markup_is_not_interpreted(self, render): + out = render([_msg("literal [bold]not markup[/bold] text")], session_id="s1") + assert "[bold]" in out + + def test_ids_are_shown_in_full(self, render): + """A displayed ID must be usable with `honcho message get`.""" + out = render([_msg()], session_id="s1", show_ids=True) + assert "V1StGXR8_Z5jdHi6B-myT" in out + assert "…" not in out + + def test_long_peer_ids_stay_distinguishable(self, render): + out = render( + [ + _msg(peer_id="user_1234567890abcdef"), + _msg(peer_id="user_1234567890abcXYZ"), + ], + session_id="s1", + ) + assert "abcdef" in out + assert "abcXYZ" in out + + def test_empty_transcript_reports_the_session(self, render): + out = render([], session_id="s1") + assert "s1" in out + assert "(empty)" in out + + +class TestNextPageHint: + def test_hint_echoes_size_and_reverse(self, render, capsys, monkeypatch): + printed: list[str] = [] + monkeypatch.setattr(output, "status", printed.append) + render([_msg()], session_id="s1", page=1, pages=3, size=10, reverse=True) + assert printed == ["more: honcho session view s1 --page 2 --size 10 --reverse"] + + def test_hint_omitted_on_the_last_page(self, render, monkeypatch): + printed: list[str] = [] + monkeypatch.setattr(output, "status", printed.append) + render([_msg()], session_id="s1", page=3, pages=3, size=10) + assert printed == []