feat(cli): add `honcho session view` transcript command

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 `<thinking>`, 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 <noreply@anthropic.com>
This commit is contained in:
Aakash Kattelu 2026-08-10 10:24:12 -04:00
parent d191c107e5
commit 0decd2764e
9 changed files with 683 additions and 9 deletions

View File

@ -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 <peer_id> --json
```bash
honcho session inspect <session_id> --json
honcho session view <session_id> --last 20 --json
honcho session view <session_id> --page 2 --size 50 --json
honcho message list <session_id> --last 20 --json
honcho session context <session_id> --json
honcho session summaries <session_id> --json

View File

@ -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

View File

@ -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 <id>` | Create or get a session (optionally `--peers` to add peers, `--metadata`) |
| `honcho session inspect <id>` | Peers, message count, summaries, config |
| `honcho session view <id>` | Transcript table (`--last N`, `--page N --size M`, `--all`, `--reverse`, `--ids`, `-p`) |
| `honcho session context <id>` | What an agent would see |
| `honcho session summaries <id>` | Short + long summaries |
| `honcho session peers <id>` / `add-peers` / `remove-peers` | Peer management |

View File

@ -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"),

View File

@ -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))

View File

@ -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)"),

View File

@ -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 `<thinking>`-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)

View File

@ -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

View File

@ -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 `<thinking>`-style tags; they must survive."""
out = render([_msg("<thinking>reasoning</thinking> answer")], session_id="s1")
assert "<thinking>" in out
assert "</thinking>" 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 == []