diff --git a/docs/snippets/cli-commands.mdx b/docs/snippets/cli-commands.mdx index 4eb9d2d3..800d45a7 100644 --- a/docs/snippets/cli-commands.mdx +++ b/docs/snippets/cli-commands.mdx @@ -305,7 +305,7 @@ honcho peer set-metadata ## honcho session -List, inspect, create, delete, and manage conversation sessions and their peers. +List, inspect, view, create, delete, and manage conversation sessions and their peers. @@ -461,6 +461,48 @@ honcho session summaries [] + +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). + +```bash +honcho session view [] +``` + + + + Show only the N most recent messages (default when no --page/--all: 50). + + + 1-indexed page of the full transcript. Use for page 2+. + + + Messages per page; requires --page (1-100, default: 50). + + + Show the full transcript (every page). + + + Newest first (default is chronological: oldest at top). + + + Include message IDs in the transcript. + + + Filter by peer ID. Short alias: `-p`. + + ## honcho workspace diff --git a/docs/v3/documentation/reference/cli.mdx b/docs/v3/documentation/reference/cli.mdx index 7b89bfeb..2b460d48 100644 --- a/docs/v3/documentation/reference/cli.mdx +++ b/docs/v3/documentation/reference/cli.mdx @@ -142,7 +142,7 @@ When you pick up a workspace and need to orient — start broad, narrow to the p ```bash honcho session inspect --json - honcho message list --last 20 --json + honcho session view --last 20 honcho session context --json honcho session summaries --json ``` @@ -150,7 +150,7 @@ When you pick up a workspace and need to orient — start broad, narrow to the p - `honcho session context` shows exactly what an agent would receive at inference time — check it before `honcho peer chat` if a response surprises you. + `honcho session context` shows exactly what an agent would receive at inference time — check it before `honcho peer chat` if a response surprises you. `honcho session view` shows the raw transcript that context was built from; it prints content verbatim, so tag-delimited and multi-line messages appear exactly as stored. ### A peer isn't learning @@ -176,7 +176,7 @@ When an agent's responses don't reflect what you expect it to know. ```bash honcho session context --json honcho session summaries --json -honcho message list --last 50 --json +honcho session view --last 50 ``` ### Dialectic returns bad answers diff --git a/honcho-cli/CHANGELOG.md b/honcho-cli/CHANGELOG.md index a334ef02..7973341a 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/scripts/generate_cli_docs.py b/honcho-cli/scripts/generate_cli_docs.py index 8fa4e223..a632b069 100644 --- a/honcho-cli/scripts/generate_cli_docs.py +++ b/honcho-cli/scripts/generate_cli_docs.py @@ -230,7 +230,7 @@ def build() -> str: body: list[str] = [] for name in sorted(root.commands): body.extend(_render_top(root.commands[name], ["honcho", name])) - return HEADER + "\n".join(body) + "\n" + return HEADER + "\n".join(body).rstrip("\n") + "\n" def main() -> int: 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..7a773fcc 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 @@ -37,15 +37,18 @@ def list_messages( handle_cmd_flags(json_output=json_output, workspace=workspace, peer=peer, session=session) sid = _get_session_id(session_id) + if last < 1: + print_error("INVALID_FLAGS", "--last must be >= 1", {"last": last}) + raise typer.Exit(1) client, config = get_client() sess = client.session(sid) 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, - # then flip to oldest-at-top / newest-at-bottom for readable display. + # Fetch newest-first so we always get the most recent N messages, 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..08dd796c 100644 --- a/honcho-cli/src/honcho_cli/commands/session.py +++ b/honcho-cli/src/honcho_cli/commands/session.py @@ -1,22 +1,29 @@ -"""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 import json +import shlex 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 +from honcho_cli.common import ( + add_common_options, + get_client, + get_flag_overrides, + 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 +142,240 @@ 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)``). +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. + 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 _next_page_command( + session_id: str, + next_page: int, + size: int, + *, + reverse: bool, + show_ids: bool, + workspace: str | None, + peer: str | None, +) -> str: + """Continuation command for the next page, carrying this invocation's scope. + + Scoping flags are echoed only when passed as flags; anything resolved from + the environment or config file resolves the same way on the next run. IDs + are shell-quoted — they may contain spaces and metacharacters, and this + string is meant to be pasted into a shell. + """ + parts = [ + "honcho", + "session", + "view", + session_id, + "--page", + str(next_page), + "--size", + str(size), + ] + if reverse: + parts.append("--reverse") + if show_ids: + parts.append("--ids") + if workspace: + parts += ["-w", workspace] + if peer: + parts += ["-p", peer] + return shlex.join(parts) + + +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. + 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). + 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: client.session() is a get-or-create POST, so build the Session directly. + 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. + 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 + + next_page_hint = None + if page_meta is not None and pages_meta is not None and page_meta < pages_meta: + # Effective overrides, not the command-level params: -w/-p also parse at + # group and top level. + overrides = get_flag_overrides() + next_page_hint = _next_page_command( + sid, + page_meta + 1, + page_size, + reverse=reverse, + show_ids=show_ids, + workspace=overrides["workspace"], + peer=overrides["peer"], + ) + + # Rendered outside the try: output failures aren't session API errors. + print_transcript( + items, + session_id=sid, + total=total, + page=page_meta, + pages=pages_meta, + show_ids=show_ids, + next_page_hint=next_page_hint, + ) + + @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/common.py b/honcho-cli/src/honcho_cli/common.py index d87a4be7..ed1f54ae 100644 --- a/honcho-cli/src/honcho_cli/common.py +++ b/honcho-cli/src/honcho_cli/common.py @@ -52,6 +52,15 @@ def get_resolved_config(): return config +def get_flag_overrides() -> dict[str, str | None]: + """Workspace/peer/session as supplied by ``-w``/``-p``/``-s`` at any level. + + Unlike :func:`get_resolved_config`, this excludes values coming from the + environment or config file. + """ + return dict(_global_overrides) + + def maybe_refresh_token(config: CLIConfig) -> None: """Refresh an expired OAuth access token in place and persist it. diff --git a/honcho-cli/src/honcho_cli/output.py b/honcho-cli/src/honcho_cli/output.py index de7902b8..2f0ec5e9 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,112 @@ 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; naive values are assumed UTC. 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, + show_ids: bool = False, + next_page_hint: str | None = None, +) -> 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. + ``next_page_hint`` is printed below the table when given. + """ + 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; + # 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)] + + # Text, not Markdown or console markup: content renders verbatim. + 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 next_page_hint: + status(f"more: {next_page_hint}") diff --git a/honcho-cli/tests/test_commands.py b/honcho-cli/tests/test_commands.py index 61de7d47..2fb29256 100644 --- a/honcho-cli/tests/test_commands.py +++ b/honcho-cli/tests/test_commands.py @@ -9,11 +9,13 @@ from __future__ import annotations import json import os +from contextlib import ExitStack, contextmanager from unittest.mock import MagicMock, patch import pytest from typer.testing import CliRunner +from honcho_cli.commands.session import _next_page_command from honcho_cli.main import app @@ -34,6 +36,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 +227,233 @@ class TestJsonContract: "created_at": "2026-01-01T00:00:00Z", } + @pytest.mark.parametrize("last", ["0", "-5"]) + def test_message_list_rejects_non_positive_last(self, cfg, runner, last): + """Non-positive --last silently returned an empty list via slice semantics.""" + 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.message.get_client", return_value=(MagicMock(), config)) as get_client: + result = runner.invoke( + app, + ["message", "list", "sess1", "--last", last, "-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_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 requires --page + ["--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) + + @pytest.mark.parametrize( + ("kwargs", "expected"), + [ + ( + {}, + "honcho session view s1 --page 2 --size 50", + ), + ( + {"reverse": True, "show_ids": True}, + "honcho session view s1 --page 2 --size 50 --reverse --ids", + ), + ( + {"workspace": "ws2", "peer": "alice"}, + "honcho session view s1 --page 2 --size 50 -w ws2 -p alice", + ), + ], + ) + def test_next_page_command_carries_the_invocation_scope(self, kwargs, expected): + """A copied hint must land on the same workspace, peer, and ordering.""" + opts = {"reverse": False, "show_ids": False, "workspace": None, "peer": None, **kwargs} + assert _next_page_command("s1", 2, 50, **opts) == expected + + @pytest.mark.parametrize( + ("session_id", "workspace", "expected_fragment"), + [ + ("has space", None, "'has space'"), + ("a;rm -rf x", None, "'a;rm -rf x'"), + ("s1", "ws$(id)", "'ws$(id)'"), + ("s1", "ws|tee", "'ws|tee'"), + ], + ) + def test_next_page_command_shell_quotes_identifiers( + self, session_id, workspace, expected_fragment + ): + """IDs only reject ?#%/\\ and control chars, so spaces and metacharacters reach here.""" + hint = _next_page_command( + session_id, + 2, + 50, + reverse=False, + show_ids=False, + workspace=workspace, + peer=None, + ) + assert expected_fragment in hint + + def test_session_view_hint_carries_group_level_scope(self, cfg, runner): + """-w/-p also parse at group level, where the command-level params are None.""" + cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"})) + session = _fake_session(_fake_page([_view_msg(1)], total=10, page=1, pages=5)) + + with _patch_view(session), patch("honcho_cli.output.use_json", return_value=False): + result = runner.invoke( + app, + ["session", "-w", "ws2", "-p", "alice", "view", "sess1", "--page", "1"], + ) + + assert result.exit_code == 0, result.stderr + assert "-w ws2" in result.stderr + assert "-p alice" in result.stderr + + 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..50352d04 --- /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_given_hint_is_printed(self, render, monkeypatch): + printed: list[str] = [] + monkeypatch.setattr(output, "status", printed.append) + render([_msg()], session_id="s1", page=1, pages=3, next_page_hint="honcho ... --page 2") + assert printed == ["more: honcho ... --page 2"] + + def test_no_hint_when_none_given(self, render, monkeypatch): + printed: list[str] = [] + monkeypatch.setattr(output, "status", printed.append) + render([_msg()], session_id="s1", page=3, pages=3) + assert printed == [] diff --git a/skills/honcho-cli/SKILL.md b/skills/honcho-cli/SKILL.md index 716d6227..e773cfa0 100644 --- a/skills/honcho-cli/SKILL.md +++ b/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 @@ -61,6 +61,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