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:
parent
d191c107e5
commit
0decd2764e
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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)"),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 == []
|
||||
Loading…
Reference in New Issue